feat: implement Extensions with real FreeSWITCH directory integration
- extensions table (tenant-scoped, RLS): number, sip_password_enc
(AES-256-GCM via packages/shared/src/crypto.ts), caller_id, context,
sofia_profile, codecs, max_registrations
- apps/api/src/extensions: CRUD (POST/GET/GET:id/DELETE), protected by a
new generic PermissionGuard (@RequirePermission decorator), tenant
resolved only from the JWT (never trusted from the client)
- SIP password is returned in plaintext only once, in the create response;
toPublicExtension() explicitly destructures the encrypted field out
(not a spread) so it can't leak by accident
- b2bcall-fs-config now resolves real directory data: Tenant.telephonyDomain
-> Extension.number, decrypts the password, builds proper directory XML
including a dial-string param (missing it caused originate to fail with
MANDATORY_IE_MISSING instead of the expected USER_NOT_REGISTERED)
- pinned FreeSWITCH's 357737{domain} to a stable value (b2bcall.local) via a
vars.xml patch in the Dockerfile -- it previously used the container's
dynamic IP, which could never match a stored telephony_domain
- added HTTP Basic auth between FreeSWITCH and fs-config
(gateway-credentials, timingSafeEqual comparison) now that the service
returns real secret data, closing the gap flagged as pending in the XML
Curl phase instead of leaving it open
- found and fixed: PermissionGuard's constructor-injected Reflector came
back undefined at runtime under tsx/esbuild (unreliable cross-file
decorator metadata emission) -- fixed with an explicit @Inject(Reflector);
worth watching for in future guards/services run via tsx
- verified end-to-end: create extension -> originate user/<ext> reports
USER_NOT_REGISTERED (found, not registered) -> delete -> back to
SUBSCRIBER_ABSENT (not found); password never reappears in any GET;
unauthenticated fs-config requests get 401
- docs/EXTENSIONS.md
This commit is contained in:
@@ -4,3 +4,5 @@ POSTGRES_APP_USER=
|
||||
POSTGRES_APP_PASSWORD=
|
||||
APP_DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public
|
||||
REDIS_URL=redis://:password@localhost:6379
|
||||
FS_CONFIG_USER=
|
||||
FS_CONFIG_PASSWORD=
|
||||
|
||||
26
TODO.md
26
TODO.md
@@ -85,7 +85,31 @@
|
||||
— testado ponta a ponta com curl (login, refresh rotation, logout, RBAC, 401/403/429)
|
||||
- [ ] Password reset por e-mail — depende de SMTP configurado
|
||||
|
||||
## PHASE 08+ — ver `agente.md` seções 39 em diante (Extensions, Trunks, Dialplan, Call Center,
|
||||
## PHASE 08 — Extensions (agente.md secao 39-40, 178)
|
||||
- [x] Tabela `extensions` (tenant-scoped, RLS) — number, sip_password_enc,
|
||||
caller_id, context, sofia_profile, codecs, max_registrations
|
||||
- [x] `packages/shared/src/crypto.ts`: AES-256-GCM (senha SIP cifrada em
|
||||
repouso), `generateStrongPassword()`, `maskSecret()`
|
||||
- [x] `apps/api/src/extensions`: CRUD (POST/GET/GET:id/DELETE), RBAC via novo
|
||||
`PermissionGuard` genérico (`@RequirePermission`), tenant só do JWT
|
||||
- [x] Senha SIP só aparece em texto puro na resposta do POST, nunca depois
|
||||
(destructuring explícito, não spread — evita vazamento por acidente)
|
||||
- [x] `b2bcall-fs-config` resolve directory real: Tenant.telephonyDomain →
|
||||
Extension.number, decifra a senha, monta XML com dial-string
|
||||
- [x] `Tenant.telephonyDomain` fixo (`b2bcall.local`) via patch no `vars.xml`
|
||||
do FreeSWITCH — antes usava o IP dinâmico do container, instável
|
||||
- [x] HTTP Basic auth entre FreeSWITCH e fs-config (`gateway-credentials`,
|
||||
timingSafeEqual) — adicionada nesta mesma fase, não deixada pendente
|
||||
- [x] Testado ponta a ponta: criar ramal → `user/1500` dá USER_NOT_REGISTERED
|
||||
(achou, sem telefone) → deletar → volta a SUBSCRIBER_ABSENT
|
||||
- [x] Achado: `PermissionGuard` injetando `Reflector` via construtor dava
|
||||
`undefined` em runtime rodando via `tsx`/esbuild (emissão de metadata
|
||||
de tipo não é 100% confiável cross-file) — corrigido com `@Inject()`
|
||||
explícito; atenção pra isso em guards/services futuros
|
||||
- [ ] Quota de ramais — depende de Plans/Entitlements (não existe ainda)
|
||||
- [ ] Multi-domínio real por tenant — hoje só um domínio fixo pra todos
|
||||
|
||||
## PHASE 09+ — ver `agente.md` seções 41 em diante (Trunks, Dialplan, Call Center,
|
||||
Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthModule } from "./health/health.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { ExtensionsModule } from "./extensions/extensions.module";
|
||||
|
||||
@Module({
|
||||
imports: [HealthModule, AuthModule],
|
||||
imports: [HealthModule, AuthModule, ExtensionsModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const PERMISSION_KEY = "permission";
|
||||
|
||||
/** Marca uma rota com a permission necessária (agente.md secao 145). */
|
||||
export const RequirePermission = (permissionKey: string) => SetMetadata(PERMISSION_KEY, permissionKey);
|
||||
43
apps/api/src/common/guards/permission.guard.ts
Normal file
43
apps/api/src/common/guards/permission.guard.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { userHasPermission } from "@b2bcall/auth";
|
||||
import { PERMISSION_KEY } from "../decorators/require-permission.decorator";
|
||||
import type { AuthenticatedRequest } from "./jwt-auth.guard";
|
||||
|
||||
/**
|
||||
* Roda depois do JwtAuthGuard. Exige que a rota tenha um tenant selecionado
|
||||
* (agente.md secao 31: nunca confiar em tenant_id do frontend — aqui vem só
|
||||
* do JWT, nunca do body/query) e que o usuário tenha a permission marcada
|
||||
* via @RequirePermission() (secao 145/146).
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
// @Inject explicito: a metadata de tipos emitida via tsx/esbuild nao e'
|
||||
// sempre confiavel o suficiente pra resolucao automatica de DI do Nest
|
||||
// (esbuild nao faz checagem de tipos cross-file completa) — sem isso,
|
||||
// `reflector` chega undefined em runtime.
|
||||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const permissionKey = this.reflector.get<string | undefined>(PERMISSION_KEY, context.getHandler());
|
||||
if (!permissionKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const user = request.user;
|
||||
if (!user) {
|
||||
throw new ForbiddenException("Nao autenticado");
|
||||
}
|
||||
if (!user.tenantId) {
|
||||
throw new ForbiddenException("Nenhum tenant selecionado (use /auth/select-tenant)");
|
||||
}
|
||||
|
||||
const allowed = await userHasPermission(user.sub, permissionKey, user.tenantId);
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(`Permissao necessaria: ${permissionKey}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
36
apps/api/src/extensions/dto/create-extension.dto.ts
Normal file
36
apps/api/src/extensions/dto/create-extension.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min, MaxLength } from "class-validator";
|
||||
|
||||
export class CreateExtensionDto {
|
||||
@IsString()
|
||||
@Matches(/^[0-9]{2,10}$/, { message: "number deve ter só dígitos (2 a 10)" })
|
||||
number!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
callerIdName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[0-9]{2,20}$/)
|
||||
callerIdNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
context?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["internal"])
|
||||
sofiaProfile?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
maxRegistrations?: number;
|
||||
}
|
||||
148
apps/api/src/extensions/extensions.controller.ts
Normal file
148
apps/api/src/extensions/extensions.controller.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import { generateStrongPassword, encryptSecret } from "@b2bcall/shared";
|
||||
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 { CreateExtensionDto } from "./dto/create-extension.dto";
|
||||
|
||||
function toPublicExtension(ext: {
|
||||
id: string;
|
||||
number: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
sipPasswordEnc: string;
|
||||
callerIdName: string | null;
|
||||
callerIdNumber: string | null;
|
||||
context: string;
|
||||
sofiaProfile: string;
|
||||
codecs: string;
|
||||
maxRegistrations: number;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
// sipPasswordEnc NUNCA sai daqui — agente.md secao 39: "Nunca mostrar
|
||||
// novamente a senha inteira". Destructuring explícito (não spread) pra
|
||||
// garantir que o campo é de fato removido, não só "esquecido" no tipo.
|
||||
const { sipPasswordEnc: _sipPasswordEnc, ...rest } = ext;
|
||||
return rest;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("extensions")
|
||||
export class ExtensionsController {
|
||||
@RequirePermission("extensions.manage")
|
||||
@Post()
|
||||
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateExtensionDto) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const tenant = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.tenant.findUniqueOrThrow({ where: { id: tenantId } }),
|
||||
);
|
||||
if (!tenant.telephonyDomain) {
|
||||
throw new BadRequestException(
|
||||
"Tenant ainda nao tem telephony_domain configurado — necessario antes de criar ramais",
|
||||
);
|
||||
}
|
||||
|
||||
const plainPassword = generateStrongPassword();
|
||||
|
||||
const extension = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.extension.create({
|
||||
data: {
|
||||
tenantId,
|
||||
number: dto.number,
|
||||
name: dto.name,
|
||||
domain: tenant.telephonyDomain!,
|
||||
sipPasswordEnc: encryptSecret(plainPassword),
|
||||
callerIdName: dto.callerIdName,
|
||||
callerIdNumber: dto.callerIdNumber,
|
||||
context: dto.context ?? "default",
|
||||
sofiaProfile: dto.sofiaProfile ?? "internal",
|
||||
maxRegistrations: dto.maxRegistrations ?? 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "EXTENSION_CREATE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "extension",
|
||||
entityId: extension.id,
|
||||
after: { number: extension.number, name: extension.name },
|
||||
});
|
||||
|
||||
return {
|
||||
...toPublicExtension(extension),
|
||||
// Só aqui, uma unica vez, na resposta da criacao.
|
||||
sipPassword: plainPassword,
|
||||
};
|
||||
}
|
||||
|
||||
@RequirePermission("extensions.view")
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
const extensions = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.extension.findMany({ where: { deletedAt: null }, orderBy: { number: "asc" } }),
|
||||
);
|
||||
return extensions.map(toPublicExtension);
|
||||
}
|
||||
|
||||
@RequirePermission("extensions.view")
|
||||
@Get(":id")
|
||||
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
const extension = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.extension.findFirst({ where: { id, deletedAt: null } }),
|
||||
);
|
||||
if (!extension) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return toPublicExtension(extension);
|
||||
}
|
||||
|
||||
@RequirePermission("extensions.manage")
|
||||
@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.extension.updateMany({
|
||||
where: { id, deletedAt: null },
|
||||
data: { deletedAt: new Date(), enabled: false },
|
||||
}),
|
||||
);
|
||||
if (result.count === 0) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "EXTENSION_DELETE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "extension",
|
||||
entityId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
7
apps/api/src/extensions/extensions.module.ts
Normal file
7
apps/api/src/extensions/extensions.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ExtensionsController } from "./extensions.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [ExtensionsController],
|
||||
})
|
||||
export class ExtensionsModule {}
|
||||
@@ -8,10 +8,17 @@ 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 packages/telephony packages/telephony
|
||||
COPY packages/database packages/database
|
||||
COPY apps/freeswitch-config apps/freeswitch-config
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter @b2bcall/freeswitch-config...
|
||||
|
||||
# `prisma generate` só precisa do schema, não de uma conexão real — o valor
|
||||
# aqui é só um placeholder pra satisfazer o carregamento do prisma.config.ts.
|
||||
ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder"
|
||||
RUN pnpm --filter @b2bcall/database exec prisma generate
|
||||
|
||||
WORKDIR /repo/apps/freeswitch-config
|
||||
|
||||
CMD ["pnpm", "exec", "tsx", "src/main.ts"]
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@b2bcall/database": "workspace:*",
|
||||
"@b2bcall/shared": "workspace:*",
|
||||
"@b2bcall/telephony": "workspace:*",
|
||||
"@fastify/formbody": "^8.0.1",
|
||||
"fastify": "5.12.1"
|
||||
},
|
||||
|
||||
@@ -1,47 +1,117 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import Fastify from "fastify";
|
||||
import formbody from "@fastify/formbody";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import { decryptSecret } from "@b2bcall/shared";
|
||||
import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony";
|
||||
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="freeswitch/xml">
|
||||
<section name="result">
|
||||
<result status="not found"/>
|
||||
</section>
|
||||
</document>`;
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} nao definido no ambiente`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
interface XmlCurlBody {
|
||||
section?: string;
|
||||
tag_name?: string;
|
||||
key_name?: string;
|
||||
key_value?: string;
|
||||
purpose?: string;
|
||||
user?: string;
|
||||
domain?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function resolveDirectoryXml(user: string | undefined, domain: string | undefined): Promise<string> {
|
||||
if (!user || !domain) {
|
||||
return NOT_FOUND_XML;
|
||||
}
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// tenants nao e' tenant-scoped (e' o proprio registro de tenants — sem
|
||||
// RLS, ver docs/TENANT_ISOLATION.md), mas so devolvemos dados de UM
|
||||
// tenant especifico depois de achar o dono do dominio.
|
||||
const tenant = await prisma.tenant.findFirst({ where: { telephonyDomain: domain, status: "ACTIVE" } });
|
||||
if (!tenant) {
|
||||
return NOT_FOUND_XML;
|
||||
}
|
||||
|
||||
const extension = await withTenantContext(prisma, tenant.id, (tx) =>
|
||||
tx.extension.findFirst({ where: { tenantId: tenant.id, number: user, enabled: true, deletedAt: null } }),
|
||||
);
|
||||
if (!extension) {
|
||||
return NOT_FOUND_XML;
|
||||
}
|
||||
|
||||
return buildDirectoryUserXml({
|
||||
domain: extension.domain,
|
||||
extensionNumber: extension.number,
|
||||
extensionName: extension.name,
|
||||
sipPassword: decryptSecret(extension.sipPasswordEnc),
|
||||
context: extension.context,
|
||||
callerIdName: extension.callerIdName ?? undefined,
|
||||
callerIdNumber: extension.callerIdNumber ?? undefined,
|
||||
tenantId: extension.tenantId,
|
||||
extensionId: extension.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const expectedUser = requireEnv("FS_CONFIG_USER");
|
||||
const expectedPassword = requireEnv("FS_CONFIG_PASSWORD");
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(formbody);
|
||||
|
||||
// Agora que este servico devolve dados reais (senha SIP decifrada), o
|
||||
// FreeSWITCH precisa se autenticar — configurado via `gateway-credentials`
|
||||
// em xml_curl.conf.xml (agente.md secao 26 + docs/EXTENSIONS.md).
|
||||
app.addHook("preHandler", async (request, reply) => {
|
||||
if (request.url === "/health") return;
|
||||
|
||||
const header = request.headers.authorization;
|
||||
if (!header?.startsWith("Basic ")) {
|
||||
reply.code(401).header("WWW-Authenticate", "Basic").send();
|
||||
return reply;
|
||||
}
|
||||
const [user, password] = Buffer.from(header.slice("Basic ".length), "base64")
|
||||
.toString("utf8")
|
||||
.split(":");
|
||||
if (!user || !password || !safeEqual(user, expectedUser) || !safeEqual(password, expectedPassword)) {
|
||||
logger.warn("tentativa de acesso com credenciais invalidas");
|
||||
reply.code(401).header("WWW-Authenticate", "Basic").send();
|
||||
return reply;
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
if (section === "directory") {
|
||||
try {
|
||||
return await resolveDirectoryXml(user, domain);
|
||||
} catch (err) {
|
||||
logger.error("erro resolvendo directory", { error: String(err) });
|
||||
return NOT_FOUND_XML;
|
||||
}
|
||||
}
|
||||
|
||||
// dialplan dinamico ainda nao existe (fase Dialplan) — a config
|
||||
// estatica vanilla continua respondendo por enquanto.
|
||||
return NOT_FOUND_XML;
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,15 @@ services:
|
||||
dockerfile: apps/freeswitch-config/Dockerfile
|
||||
container_name: b2bcall-fs-config
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
# Hostname interno do compose (postgres), nao localhost — ver
|
||||
# docs/NETWORK_ARCHITECTURE.md.
|
||||
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
FS_CONFIG_USER: ${FS_CONFIG_USER}
|
||||
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
|
||||
# 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))"]
|
||||
@@ -57,6 +66,8 @@ services:
|
||||
- fs-config
|
||||
environment:
|
||||
ESL_PASSWORD: ${ESL_PASSWORD}
|
||||
FS_CONFIG_USER: ${FS_CONFIG_USER}
|
||||
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
|
||||
# Nenhuma porta publicada no host: SIP/RTP ainda não têm troncos reais
|
||||
# configurados, e o Event Socket (8021) só deve ser alcançável por outros
|
||||
# containers na rede interna do compose (agente.md secao 22).
|
||||
|
||||
115
docs/EXTENSIONS.md
Normal file
115
docs/EXTENSIONS.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Extensions (Ramais)
|
||||
|
||||
Primeira fase que dá dado real de negócio ao FreeSWITCH via `mod_xml_curl`
|
||||
(agente.md secao 39-40, 178).
|
||||
|
||||
## Modelo
|
||||
|
||||
`extensions` (tenant-scoped, RLS — mesmo padrão de `tenant_memberships`):
|
||||
`number`, `name`, `domain`, `sip_password_enc`, `caller_id_name/number`,
|
||||
`context`, `sofia_profile`, `codecs`, `max_registrations`, `enabled`.
|
||||
`UNIQUE(tenant_id, number)` — números só precisam ser únicos dentro do
|
||||
tenant (secao 34).
|
||||
|
||||
## Senha SIP (secao 178)
|
||||
|
||||
- Gerada com `generateStrongPassword()` (24 caracteres alfanuméricos,
|
||||
`crypto.randomBytes`).
|
||||
- Cifrada em repouso com AES-256-GCM (`packages/shared/src/crypto.ts`),
|
||||
chave em `ENCRYPTION_KEY` (.env, 32 bytes hex) — nunca no PostgreSQL.
|
||||
- **Só aparece em texto puro na resposta do `POST /extensions`, uma única
|
||||
vez.** `GET`/`list` nunca devolvem `sipPasswordEnc` nem a senha — a função
|
||||
`toPublicExtension()` faz destructuring explícito do campo (não spread) pra
|
||||
garantir isso é removido de fato, não só "esquecido" no tipo TypeScript.
|
||||
|
||||
## API (`apps/api/src/extensions`)
|
||||
|
||||
```
|
||||
POST /extensions extensions.manage cria (gera+cifra senha, devolve 1x)
|
||||
GET /extensions extensions.view lista (sem senha)
|
||||
GET /extensions/:id extensions.view detalhe (sem senha)
|
||||
DELETE /extensions/:id extensions.manage soft delete (deletedAt + enabled=false)
|
||||
```
|
||||
|
||||
Novo `PermissionGuard` genérico (`@RequirePermission('extensions.manage')`)
|
||||
— roda depois do `JwtAuthGuard`, exige tenant selecionado no JWT (nunca
|
||||
aceita tenant_id do client) e chama `userHasPermission()` de `packages/auth`.
|
||||
|
||||
**Achado real durante os testes**: o `PermissionGuard` injeta `Reflector` via
|
||||
construtor — padrão documentado do NestJS. Rodando via `tsx` (esbuild), o
|
||||
`reflector` chegava `undefined` em runtime (`TypeError: Cannot read
|
||||
properties of undefined`), porque esbuild não faz emissão de
|
||||
`design:paramtypes` com checagem de tipos completa entre arquivos (limitação
|
||||
conhecida do esbuild, diferente do `tsc`). Resolvido com `@Inject(Reflector)`
|
||||
explícito no construtor. Isso é um risco real pra qualquer guard/serviço
|
||||
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.
|
||||
|
||||
## `b2bcall-fs-config` agora responde directory de verdade
|
||||
|
||||
Fluxo `section === "directory"`:
|
||||
|
||||
1. `Tenant.findFirst({ telephonyDomain: domain, status: "ACTIVE" })` — tenants
|
||||
não são RLS-protected (é o registro da plataforma).
|
||||
2. `withTenantContext(tenant.id) → Extension.findFirst({ number: user, enabled: true })`.
|
||||
3. `buildDirectoryUserXml()` (`packages/telephony`) monta o XML, incluindo
|
||||
`b2bcall_tenant_id`/`b2bcall_extension_id` como channel variables (secao 81
|
||||
— assim qualquer chamada desse ramal já carrega a origem).
|
||||
|
||||
**Achado real**: a primeira versão do XML não incluía o bloco
|
||||
`<domain><params><param name="dial-string".../></params></domain>` que a
|
||||
config vanilla tem em `directory/default.xml`. Sem isso, `originate
|
||||
user/1500 &park()` falhava com `MANDATORY_IE_MISSING` em vez do
|
||||
`USER_NOT_REGISTERED` esperado — o FreeSWITCH não sabia montar o dialstring
|
||||
pro endpoint `user/`. Corrigido copiando o mesmo template de `dial-string` da
|
||||
config vanilla.
|
||||
|
||||
## `$${domain}` fixo
|
||||
|
||||
`Tenant.telephonyDomain` precisa bater com o que o FreeSWITCH manda como
|
||||
`domain` no POST do xml_curl. Por padrão, a config vanilla usa
|
||||
`domain=$${local_ip_v4}` — o IP do container, que muda a cada restart e
|
||||
nunca seria estável o suficiente pra configurar em um tenant. Corrigido no
|
||||
`Dockerfile` do FreeSWITCH com um `sed` fixando `$${domain}` pra
|
||||
`b2bcall.local` (configurável via `ARG DEFAULT_SIP_DOMAIN`). Multi-domínio
|
||||
real por tenant (múltiplos domínios simultâneos, um por tenant) ainda não
|
||||
está resolvido — hoje só suporta um domínio fixo pra todos; isso é uma
|
||||
limitação genuína a resolver quando existir gestão de domínio por tenant de
|
||||
verdade (fora do escopo desta fase).
|
||||
|
||||
## Verificado ponta a ponta
|
||||
|
||||
```bash
|
||||
POST /extensions {"number":"1500","name":"Ramal de Teste"} # 201, senha aparece 1x
|
||||
GET /extensions/:id # confirma senha nunca reaparece
|
||||
|
||||
# fs-config resolve com a senha certa (decifrada corretamente):
|
||||
curl -d "section=directory&user=1500&domain=b2bcall.local" http://fs-config:8080/
|
||||
|
||||
# FreeSWITCH:
|
||||
originate user/1500 &park() # USER_NOT_REGISTERED (achou o ramal, sem telefone registrado)
|
||||
originate user/1501 &park() # SUBSCRIBER_ABSENT (nao existe)
|
||||
|
||||
DELETE /extensions/:id
|
||||
originate user/1500 &park() # volta a SUBSCRIBER_ABSENT
|
||||
```
|
||||
|
||||
## Autenticação HTTP `fs-config` ↔ FreeSWITCH
|
||||
|
||||
Adicionada nesta mesma fase (assim que o serviço passou a devolver dados
|
||||
reais, deixou de ser opcional): HTTP Basic, credenciais em `FS_CONFIG_USER`/
|
||||
`FS_CONFIG_PASSWORD` (.env, geradas com `openssl rand`). FreeSWITCH manda via
|
||||
`gateway-credentials` em `xml_curl.conf.xml` (substituído em runtime pelo
|
||||
`entrypoint.sh`, mesmo padrão do `ESL_PASSWORD` — nunca fica na imagem).
|
||||
`fs-config` compara com `timingSafeEqual` (evita timing attack), libera só
|
||||
`/health` sem auth (usado pelo healthcheck do Docker). Verificado: requisição
|
||||
sem credenciais recebe 401; o FreeSWITCH (com `gateway-credentials`
|
||||
configurado) continua funcionando normalmente.
|
||||
|
||||
## O que falta
|
||||
|
||||
- Quota de ramais (`max_extensions` do plano, secao 57) — depende da fase
|
||||
Plans/Entitlements, que ainda não existe.
|
||||
- Tela "Telefonia → Ramais" (frontend) — fase Frontend, bem mais adiante.
|
||||
- Multi-domínio real por tenant (ver acima).
|
||||
@@ -46,6 +46,15 @@ COPY overrides/autoload_configs/modules.conf.xml /etc/freeswitch/autoload_config
|
||||
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
|
||||
|
||||
# Pino $${domain} num valor estavel em vez do IP dinamico do container
|
||||
# (vars.xml vanilla usa "domain=$${local_ip_v4}", que muda a cada restart e
|
||||
# nunca bateria com Tenant.telephonyDomain). Ver docs/EXTENSIONS.md.
|
||||
ARG DEFAULT_SIP_DOMAIN=b2bcall.local
|
||||
RUN sed -i "s/data=\"domain=\$\${local_ip_v4}\"/data=\"domain=${DEFAULT_SIP_DOMAIN}\"/" \
|
||||
/etc/freeswitch/vars.xml \
|
||||
&& grep -q "domain=${DEFAULT_SIP_DOMAIN}" /etc/freeswitch/vars.xml
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
|
||||
@@ -6,8 +6,15 @@
|
||||
set -eu
|
||||
|
||||
: "${ESL_PASSWORD:?ESL_PASSWORD precisa estar definido no ambiente do container}"
|
||||
: "${FS_CONFIG_USER:?FS_CONFIG_USER precisa estar definido no ambiente do container}"
|
||||
: "${FS_CONFIG_PASSWORD:?FS_CONFIG_PASSWORD precisa estar definido no ambiente do container}"
|
||||
|
||||
sed -i "s/__ESL_PASSWORD__/${ESL_PASSWORD}/" \
|
||||
/etc/freeswitch/autoload_configs/event_socket.conf.xml
|
||||
|
||||
sed -i \
|
||||
-e "s/__FS_CONFIG_USER__/${FS_CONFIG_USER}/" \
|
||||
-e "s/__FS_CONFIG_PASSWORD__/${FS_CONFIG_PASSWORD}/" \
|
||||
/etc/freeswitch/autoload_configs/xml_curl.conf.xml
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<configuration name="xml_curl.conf" description="cURL XML Gateway">
|
||||
<bindings>
|
||||
<binding name="b2bcall-fs-config">
|
||||
<!-- b2bcall-fs-config ainda so responde "not found" pra tudo (nao
|
||||
existe extensions/dialplan persistidos ainda — fases seguintes).
|
||||
A config estatica vanilla continua valendo como fallback. -->
|
||||
<!-- b2bcall-fs-config responde directory de verdade (extensions) e
|
||||
"not found" pra dialplan ainda (fase seguinte). A config estatica
|
||||
vanilla continua valendo como fallback quando "not found".
|
||||
Credenciais substituidas em runtime pelo entrypoint.sh — nunca
|
||||
ficam de verdade na imagem (mesmo padrao do ESL_PASSWORD). -->
|
||||
<param name="gateway-url" value="http://fs-config:8080/" bindings="directory|dialplan"/>
|
||||
<param name="gateway-credentials" value="__FS_CONFIG_USER__:__FS_CONFIG_PASSWORD__"/>
|
||||
<param name="auth-scheme" value="basic"/>
|
||||
<param name="timeout" value="5"/>
|
||||
</binding>
|
||||
</bindings>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "extensions" (
|
||||
"id" UUID NOT NULL,
|
||||
"tenant_id" UUID NOT NULL,
|
||||
"number" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"domain" TEXT NOT NULL,
|
||||
"sip_password_enc" TEXT NOT NULL,
|
||||
"caller_id_name" TEXT,
|
||||
"caller_id_number" TEXT,
|
||||
"context" TEXT NOT NULL DEFAULT 'default',
|
||||
"sofia_profile" TEXT NOT NULL DEFAULT 'internal',
|
||||
"codecs" TEXT NOT NULL DEFAULT 'PCMU,PCMA,OPUS',
|
||||
"max_registrations" INTEGER NOT NULL DEFAULT 1,
|
||||
"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 "extensions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "extensions_tenant_id_idx" ON "extensions"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "extensions_tenant_id_number_key" ON "extensions"("tenant_id", "number");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "extensions" ADD CONSTRAINT "extensions_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Tabela de negocio tenant-scoped: RLS obrigatorio (ver docs/TENANT_ISOLATION.md).
|
||||
ALTER TABLE "extensions" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "extensions" FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "tenant_isolation" ON "extensions"
|
||||
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
|
||||
@@ -34,6 +34,7 @@ model Tenant {
|
||||
|
||||
memberships TenantMembership[]
|
||||
userRoles UserRole[]
|
||||
extensions Extension[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
@@ -181,3 +182,38 @@ model TenantMembership {
|
||||
@@index([tenantId])
|
||||
@@map("tenant_memberships")
|
||||
}
|
||||
|
||||
// Tabela tenant-scoped protegida por Row Level Security. sipPasswordEnc
|
||||
// guarda a senha SIP cifrada (AES-256-GCM, ver packages/shared/src/crypto.ts)
|
||||
// — nunca texto puro (agente.md secao 178).
|
||||
model Extension {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
|
||||
number String
|
||||
name String
|
||||
domain String
|
||||
|
||||
sipPasswordEnc String @map("sip_password_enc")
|
||||
|
||||
callerIdName String? @map("caller_id_name")
|
||||
callerIdNumber String? @map("caller_id_number")
|
||||
|
||||
context String @default("default")
|
||||
sofiaProfile String @default("internal") @map("sofia_profile")
|
||||
codecs String @default("PCMU,PCMA,OPUS") @map("codecs")
|
||||
|
||||
maxRegistrations Int @default(1) @map("max_registrations")
|
||||
|
||||
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])
|
||||
|
||||
@@unique([tenantId, number])
|
||||
@@index([tenantId])
|
||||
@@map("extensions")
|
||||
}
|
||||
|
||||
65
packages/shared/src/crypto.ts
Normal file
65
packages/shared/src/crypto.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12; // recomendado pro GCM
|
||||
|
||||
/**
|
||||
* Cifra secrets em repouso (senha SIP, API keys de IA — agente.md secoes
|
||||
* 101, 178) com AES-256-GCM. A master key nunca fica no PostgreSQL — só em
|
||||
* `ENCRYPTION_KEY` (.env), 32 bytes em hex.
|
||||
*
|
||||
* Formato de saída: `<iv_hex>:<authTag_hex>:<ciphertext_hex>` — tudo
|
||||
* necessário pra decifrar fica junto, exceto a chave.
|
||||
*/
|
||||
function getKey(): Buffer {
|
||||
const hex = process.env.ENCRYPTION_KEY;
|
||||
if (!hex) {
|
||||
throw new Error("ENCRYPTION_KEY nao definida no ambiente");
|
||||
}
|
||||
const key = Buffer.from(hex, "hex");
|
||||
if (key.length !== 32) {
|
||||
throw new Error("ENCRYPTION_KEY precisa ter 32 bytes (64 caracteres hex) para AES-256");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function encryptSecret(plainText: string): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plainText, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return `${iv.toString("hex")}:${authTag.toString("hex")}:${ciphertext.toString("hex")}`;
|
||||
}
|
||||
|
||||
export function decryptSecret(encoded: string): string {
|
||||
const key = getKey();
|
||||
const [ivHex, authTagHex, ciphertextHex] = encoded.split(":");
|
||||
if (!ivHex || !authTagHex || !ciphertextHex) {
|
||||
throw new Error("Formato invalido de secret cifrado");
|
||||
}
|
||||
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, "hex"));
|
||||
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertextHex, "hex")),
|
||||
decipher.final(),
|
||||
]);
|
||||
return plaintext.toString("utf8");
|
||||
}
|
||||
|
||||
/** Senha SIP forte, alfanumérica (evita caracteres que compliquem SIP/URI). */
|
||||
export function generateStrongPassword(length = 24): string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const bytes = randomBytes(length);
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += alphabet[bytes[i]! % alphabet.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Máscara pra exibição (nunca a senha inteira de novo — agente.md secao 39). */
|
||||
export function maskSecret(plainText: string): string {
|
||||
if (plainText.length <= 4) return "****";
|
||||
return `${plainText.slice(0, 2)}${"*".repeat(plainText.length - 4)}${plainText.slice(-2)}`;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "@b2bcall/types";
|
||||
export * from "./logger";
|
||||
export * from "./crypto";
|
||||
|
||||
72
packages/telephony/src/directory-xml.ts
Normal file
72
packages/telephony/src/directory-xml.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/** Escapa texto pra uso seguro dentro de atributos/elementos XML. */
|
||||
function xmlEscape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export interface DirectoryUserParams {
|
||||
domain: string;
|
||||
extensionNumber: string;
|
||||
extensionName: string;
|
||||
sipPassword: string;
|
||||
context: string;
|
||||
callerIdName?: string;
|
||||
callerIdNumber?: string;
|
||||
tenantId: string;
|
||||
extensionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monta o XML de directory que o FreeSWITCH espera do mod_xml_curl pra
|
||||
* autenticar/rotear um ramal (agente.md secao 26). `b2bcall_tenant_id` e
|
||||
* `b2bcall_extension_id` viram channel variables em toda chamada desse
|
||||
* ramal — é assim que o resto do sistema volta a saber de qual tenant uma
|
||||
* chamada é (secao 81).
|
||||
*/
|
||||
export function buildDirectoryUserXml(params: DirectoryUserParams): string {
|
||||
const callerIdName = xmlEscape(params.callerIdName ?? params.extensionName);
|
||||
const callerIdNumber = xmlEscape(params.callerIdNumber ?? params.extensionNumber);
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="freeswitch/xml">
|
||||
<section name="directory">
|
||||
<domain name="${xmlEscape(params.domain)}">
|
||||
<params>
|
||||
<!-- Sem isto, o endpoint "user/" nao sabe montar o dialstring pra
|
||||
alcançar o contato registrado (mesmo padrão da config vanilla
|
||||
em directory/default.xml). -->
|
||||
<param name="dial-string" value="{^^:sip_invite_domain=\${dialed_domain}:presence_id=\${dialed_user}@\${dialed_domain}}\${sofia_contact(*/\${dialed_user}@\${dialed_domain})}"/>
|
||||
</params>
|
||||
<groups>
|
||||
<group name="default">
|
||||
<users>
|
||||
<user id="${xmlEscape(params.extensionNumber)}">
|
||||
<params>
|
||||
<param name="password" value="${xmlEscape(params.sipPassword)}"/>
|
||||
</params>
|
||||
<variables>
|
||||
<variable name="user_context" value="${xmlEscape(params.context)}"/>
|
||||
<variable name="effective_caller_id_name" value="${callerIdName}"/>
|
||||
<variable name="effective_caller_id_number" value="${callerIdNumber}"/>
|
||||
<variable name="b2bcall_tenant_id" value="${xmlEscape(params.tenantId)}"/>
|
||||
<variable name="b2bcall_extension_id" value="${xmlEscape(params.extensionId)}"/>
|
||||
</variables>
|
||||
</user>
|
||||
</users>
|
||||
</group>
|
||||
</groups>
|
||||
</domain>
|
||||
</section>
|
||||
</document>`;
|
||||
}
|
||||
|
||||
export const NOT_FOUND_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="freeswitch/xml">
|
||||
<section name="result">
|
||||
<result status="not found"/>
|
||||
</section>
|
||||
</document>`;
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./types";
|
||||
export * from "./normalize-event";
|
||||
export * from "./freeswitch-provider";
|
||||
export * from "./directory-xml";
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -72,9 +72,15 @@ importers:
|
||||
|
||||
apps/freeswitch-config:
|
||||
dependencies:
|
||||
'@b2bcall/database':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/database
|
||||
'@b2bcall/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
'@b2bcall/telephony':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/telephony
|
||||
'@fastify/formbody':
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.2
|
||||
|
||||
Reference in New Issue
Block a user