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:
35
TODO.md
35
TODO.md
@@ -140,7 +140,40 @@
|
|||||||
produção. Ver docs/TRUNKS.md.
|
produção. Ver docs/TRUNKS.md.
|
||||||
- [ ] Quota de troncos — depende de Plans/Entitlements (não existe ainda)
|
- [ ] 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)
|
Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -3,15 +3,16 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/main.ts",
|
"dev": "tsc -p tsconfig.json && tsx dist/main.js",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/main.js",
|
"start": "tsx dist/main.js",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@b2bcall/auth": "workspace:*",
|
"@b2bcall/auth": "workspace:*",
|
||||||
"@b2bcall/database": "workspace:*",
|
"@b2bcall/database": "workspace:*",
|
||||||
"@b2bcall/shared": "workspace:*",
|
"@b2bcall/shared": "workspace:*",
|
||||||
|
"@b2bcall/telephony": "workspace:*",
|
||||||
"@fastify/cors": "11.3.0",
|
"@fastify/cors": "11.3.0",
|
||||||
"@fastify/helmet": "13.1.1",
|
"@fastify/helmet": "13.1.1",
|
||||||
"@fastify/rate-limit": "11.2.0",
|
"@fastify/rate-limit": "11.2.0",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"@nestjs/platform-fastify": "^12.0.1",
|
"@nestjs/platform-fastify": "^12.0.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"fast-xml-parser": "5.11.1",
|
||||||
"fastify": "5.12.1",
|
"fastify": "5.12.1",
|
||||||
"ioredis": "^6.0.0",
|
"ioredis": "^6.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { HealthModule } from "./health/health.module";
|
|||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { ExtensionsModule } from "./extensions/extensions.module";
|
import { ExtensionsModule } from "./extensions/extensions.module";
|
||||||
import { TrunksModule } from "./trunks/trunks.module";
|
import { TrunksModule } from "./trunks/trunks.module";
|
||||||
|
import { DialplanModule } from "./dialplan/dialplan.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule],
|
imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule, DialplanModule],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
97
apps/api/src/dialplan/dialplan-extensions.controller.ts
Normal file
97
apps/api/src/dialplan/dialplan-extensions.controller.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
168
apps/api/src/dialplan/dialplan-versions.controller.ts
Normal file
168
apps/api/src/dialplan/dialplan-versions.controller.ts
Normal 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 } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/api/src/dialplan/dialplan.module.ts
Normal file
8
apps/api/src/dialplan/dialplan.module.ts
Normal 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 {}
|
||||||
12
apps/api/src/dialplan/dto/action.dto.ts
Normal file
12
apps/api/src/dialplan/dto/action.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
55
apps/api/src/dialplan/dto/create-dialplan-extension.dto.ts
Normal file
55
apps/api/src/dialplan/dto/create-dialplan-extension.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ interface XmlCurlBody {
|
|||||||
purpose?: string;
|
purpose?: string;
|
||||||
user?: string;
|
user?: string;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
context?: string;
|
||||||
|
"Caller-Context"?: string;
|
||||||
|
hostname?: string;
|
||||||
[key: string]: unknown;
|
[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() {
|
async function main() {
|
||||||
const expectedUser = requireEnv("FS_CONFIG_USER");
|
const expectedUser = requireEnv("FS_CONFIG_USER");
|
||||||
const expectedPassword = requireEnv("FS_CONFIG_PASSWORD");
|
const expectedPassword = requireEnv("FS_CONFIG_PASSWORD");
|
||||||
@@ -114,8 +149,15 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// dialplan dinamico ainda nao existe (fase Dialplan) — a config
|
if (section === "dialplan") {
|
||||||
// estatica vanilla continua respondendo por enquanto.
|
try {
|
||||||
|
return await resolveDialplanXml(request.body ?? {});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("erro resolvendo dialplan", { error: String(err) });
|
||||||
|
return NOT_FOUND_XML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NOT_FOUND_XML;
|
return NOT_FOUND_XML;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
91
docs/DIALPLAN.md
Normal file
91
docs/DIALPLAN.md
Normal file
@@ -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
|
||||||
|
`<condition>` 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=<tenant>}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 `<condition>` 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).
|
||||||
@@ -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
|
explícito sempre que o dev/runtime for via `tsx`**, ou considerar migrar
|
||||||
`apps/api` pra build real (`tsc`) mais adiante.
|
`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
|
## `b2bcall-fs-config` agora responde directory de verdade
|
||||||
|
|
||||||
Fluxo `section === "directory"`:
|
Fluxo `section === "directory"`:
|
||||||
|
|||||||
75
docs/VALIDATION_PIPE_BUG.md
Normal file
75
docs/VALIDATION_PIPE_BUG.md
Normal file
@@ -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.
|
||||||
@@ -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);
|
||||||
@@ -36,6 +36,8 @@ model Tenant {
|
|||||||
userRoles UserRole[]
|
userRoles UserRole[]
|
||||||
extensions Extension[]
|
extensions Extension[]
|
||||||
trunks Trunk[]
|
trunks Trunk[]
|
||||||
|
dialplanExtensions DialplanExtension[]
|
||||||
|
dialplanVersions DialplanVersion[]
|
||||||
|
|
||||||
@@map("tenants")
|
@@map("tenants")
|
||||||
}
|
}
|
||||||
@@ -311,3 +313,69 @@ model Trunk {
|
|||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@map("trunks")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
101
packages/telephony/src/dialplan-xml.ts
Normal file
101
packages/telephony/src/dialplan-xml.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
function xmlEscape(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.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 <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>`;
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ export * from "./normalize-event";
|
|||||||
export * from "./freeswitch-provider";
|
export * from "./freeswitch-provider";
|
||||||
export * from "./directory-xml";
|
export * from "./directory-xml";
|
||||||
export * from "./gateway-xml";
|
export * from "./gateway-xml";
|
||||||
|
export * from "./dialplan-xml";
|
||||||
|
|||||||
61
pnpm-lock.yaml
generated
61
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@b2bcall/shared':
|
'@b2bcall/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
'@b2bcall/telephony':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/telephony
|
||||||
'@fastify/cors':
|
'@fastify/cors':
|
||||||
specifier: 11.3.0
|
specifier: 11.3.0
|
||||||
version: 11.3.0
|
version: 11.3.0
|
||||||
@@ -47,6 +50,9 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.15.1
|
specifier: ^0.15.1
|
||||||
version: 0.15.1
|
version: 0.15.1
|
||||||
|
fast-xml-parser:
|
||||||
|
specifier: 5.11.1
|
||||||
|
version: 5.11.1
|
||||||
fastify:
|
fastify:
|
||||||
specifier: 5.12.1
|
specifier: 5.12.1
|
||||||
version: 5.12.1
|
version: 5.12.1
|
||||||
@@ -479,6 +485,9 @@ packages:
|
|||||||
'@fastify/view':
|
'@fastify/view':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nodable/entities@3.0.0':
|
||||||
|
resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==}
|
||||||
|
|
||||||
'@node-rs/argon2-android-arm-eabi@2.1.0':
|
'@node-rs/argon2-android-arm-eabi@2.1.0':
|
||||||
resolution: {integrity: sha512-hdWo5kb4eFRbHjdu4O6dVlRPI/CR1vbkJpe3Z9kF2s0Kp42428wg8AxI+8Cv4mygdW309BpUBf3sZaqXBNpdpw==}
|
resolution: {integrity: sha512-hdWo5kb4eFRbHjdu4O6dVlRPI/CR1vbkJpe3Z9kF2s0Kp42428wg8AxI+8Cv4mygdW309BpUBf3sZaqXBNpdpw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -815,6 +824,9 @@ packages:
|
|||||||
ajv@8.20.0:
|
ajv@8.20.0:
|
||||||
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
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:
|
atomic-sleep@1.0.0:
|
||||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
@@ -1000,6 +1012,13 @@ packages:
|
|||||||
fast-uri@4.1.3:
|
fast-uri@4.1.3:
|
||||||
resolution: {integrity: sha512-7+72G6vLt7jjNas8SmSATx2qeyRIjxeqO3i4IkmDTxlqYZRKANhOe1bnovcp4WZmvsYrp60WyqPyHqgRiX0yXw==}
|
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:
|
fastify-plugin@5.1.0:
|
||||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||||
|
|
||||||
@@ -1082,6 +1101,9 @@ packages:
|
|||||||
is-property@1.0.2:
|
is-property@1.0.2:
|
||||||
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
||||||
|
|
||||||
|
is-unsafe@2.0.2:
|
||||||
|
resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==}
|
||||||
|
|
||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
@@ -1143,6 +1165,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||||
engines: {node: '>=14.0.0'}
|
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:
|
path-key@3.1.1:
|
||||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1377,6 +1403,9 @@ packages:
|
|||||||
std-env@3.10.0:
|
std-env@3.10.0:
|
||||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
|
strnum@2.4.2:
|
||||||
|
resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==}
|
||||||
|
|
||||||
strtok3@10.3.5:
|
strtok3@10.3.5:
|
||||||
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1434,6 +1463,10 @@ packages:
|
|||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
xml-naming@0.3.0:
|
||||||
|
resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==}
|
||||||
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
xtend@4.0.2:
|
xtend@4.0.2:
|
||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
@@ -1648,6 +1681,8 @@ snapshots:
|
|||||||
reusify: 1.1.0
|
reusify: 1.1.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@nodable/entities@3.0.0': {}
|
||||||
|
|
||||||
'@node-rs/argon2-android-arm-eabi@2.1.0':
|
'@node-rs/argon2-android-arm-eabi@2.1.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2016,6 +2051,8 @@ snapshots:
|
|||||||
json-schema-traverse: 1.0.0
|
json-schema-traverse: 1.0.0
|
||||||
require-from-string: 2.0.2
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
|
anynum@1.0.1: {}
|
||||||
|
|
||||||
atomic-sleep@1.0.0: {}
|
atomic-sleep@1.0.0: {}
|
||||||
|
|
||||||
avvio@9.3.0:
|
avvio@9.3.0:
|
||||||
@@ -2209,6 +2246,20 @@ snapshots:
|
|||||||
|
|
||||||
fast-uri@4.1.3: {}
|
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@5.1.0: {}
|
||||||
|
|
||||||
fastify-plugin@6.0.0: {}
|
fastify-plugin@6.0.0: {}
|
||||||
@@ -2305,6 +2356,8 @@ snapshots:
|
|||||||
|
|
||||||
is-property@1.0.2: {}
|
is-property@1.0.2: {}
|
||||||
|
|
||||||
|
is-unsafe@2.0.2: {}
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
iterare@1.2.1: {}
|
iterare@1.2.1: {}
|
||||||
@@ -2364,6 +2417,8 @@ snapshots:
|
|||||||
|
|
||||||
on-exit-leak-free@2.1.2: {}
|
on-exit-leak-free@2.1.2: {}
|
||||||
|
|
||||||
|
path-expression-matcher@1.6.2: {}
|
||||||
|
|
||||||
path-key@3.1.1: {}
|
path-key@3.1.1: {}
|
||||||
|
|
||||||
path-to-regexp@8.4.2: {}
|
path-to-regexp@8.4.2: {}
|
||||||
@@ -2561,6 +2616,10 @@ snapshots:
|
|||||||
|
|
||||||
std-env@3.10.0: {}
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
|
strnum@2.4.2:
|
||||||
|
dependencies:
|
||||||
|
anynum: 1.0.1
|
||||||
|
|
||||||
strtok3@10.3.5:
|
strtok3@10.3.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tokenizer/token': 0.3.0
|
'@tokenizer/token': 0.3.0
|
||||||
@@ -2605,6 +2664,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
xml-naming@0.3.0: {}
|
||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
zeptomatch@2.1.0:
|
zeptomatch@2.1.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user