- 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
- trunks table (tenant-scoped, RLS): host/proxy/realm, register,
username/password_enc (AES-256-GCM), dtmf_mode, ping, transport, and a
status/status_updated_at pair meant to be driven by FreeSWITCH events
- apps/api/src/trunks: CRUD (POST/GET/GET:id/DELETE), same RBAC/tenant
pattern as Extensions, password never exposed in any GET
- packages/telephony: buildGatewayXml() generates a Sofia gateway XML file
- b2bcall-fs-config now writes sip_profiles/external/<trunk_id>.xml (shared
Docker volume with FreeSWITCH -- the vanilla external profile already
includes external/*.xml) and runs 'sofia profile external rescan' over
ESL; syncs on boot and on demand via Redis pub/sub
(b2bcall:trunks:sync), since apps/api runs on the host and fs-config has
no port published to reach directly
- added FreeSwitchTelephonyProvider.waitUntilConnected() to fix a startup
race: the first sync ran before the ESL connection had settled, logging
a harmless but noisy error
- verified end-to-end with a fake host: create trunk -> gateway file
written -> FreeSWITCH shows the real gateway (FAIL_WAIT, expected) ->
delete -> file removed (cleanup also correctly swept the stale
'example.com' gateway that had been copied into the volume from the
vanilla image)
- apps/freeswitch-events/src/trunk-status.ts: written to update Trunk.status
from sofia::gateway_state events, using the same normalizeEslEvent path
already proven for CHANNEL_* events
- KNOWN GAP, documented rather than glossed over: monitored fs-events for
~90s while the gateway visibly transitioned states in FreeSWITCH
(FAIL_WAIT/DOWN) and no sofia::gateway_state event was observed arriving.
CUSTOM/sofia::* events have not actually been proven working end-to-end
in this session -- only CHANNEL_* events have been. Needs verification
against a real SIP target before the status auto-update can be trusted
in production. See docs/TRUNKS.md and TODO.md.
- docs/TRUNKS.md
- 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
- apps/freeswitch-config (b2bcall-fs-config): Fastify service implementing
the mod_xml_curl HTTP protocol (form-encoded POST -> XML response),
containerized, no host port published
- reactivated mod_xml_curl in FreeSWITCH, binding restricted to
directory|dialplan only (configuration was removed after testing showed
it firing several unnecessary HTTP round-trips at boot for module
configs we don't need dynamic — matches agente.md's own 'don't put every
critical config through XML Curl' guidance)
- no extensions/dialplan tables exist yet (next phases), so the service
always answers 'not found' for now — this phase only proves the wire
protocol works without breaking the static vanilla config fallback
- verified end-to-end: user/8888 (nowhere) -> SUBSCRIBER_ABSENT via
fs-config; user/1000 (static vanilla extension) -> USER_NOT_REGISTERED,
proving FreeSWITCH correctly falls through to static XML when xml_curl
says not found
- docs/XML_CURL.md, including the not-yet-authenticated endpoint note (fine
while it only returns not-found; needs gateway-credentials before serving
real directory/dialplan data)
- packages/telephony: TelephonyProvider interface (agente.md secao 25) and
FreeSwitchTelephonyProvider implementation over the 'esl' library
(actively maintained, TypeScript-native, built-in reconnect-with-backoff
satisfying secao 195); normalizeEslEvent() translates raw ESL events into
the internal vocabulary (secao 24)
- apps/freeswitch-events (b2bcall-fs-events): permanent ESL connection,
resubscribes on every reconnect, publishes normalized events to the
'b2bcall:events' Redis pub/sub channel; containerized (Dockerfile +
docker-compose service) since its whole job is reaching the freeswitch
container by internal hostname
- packages/shared: reusable createLogger() (structured JSON per secao 189),
fixed a BigInt serialization crash surfaced by the esl library's error
stats
- found and fixed a real FreeSWITCH 1.11 default: without an explicit
apply-inbound-acl, mod_event_socket silently rejects any non-loopback
connection ('Access Denied, go away.') even with the correct password —
added a dedicated ACL (loopback + the Docker Compose network range, never
0.0.0.0/0) in infrastructure/freeswitch/overrides/autoload_configs/
- verified end-to-end with a local loopback test call: CALL_CREATED ->
CALL_ANSWERED -> CALL_ENDED observed on the Redis channel with the
correct callUuid and hangup cause
- docs/EVENT_SOCKET.md
- infrastructure/freeswitch/Dockerfile: debian:trixie-slim + SignalWire
packaged freeswitch-meta-vanilla, avoiding a C/C++ build on a 1.9GB RAM VM
- FREESWITCH_PAT used only via Docker BuildKit secret, apt credentials file
created and deleted within the same RUN — verified absent from the final
image with docker history
- minimal module set (agente.md secao 15): sofia, event_socket, commands,
dptools, callcenter, avmd, curl, local_stream, etc. mod_xml_curl installed
but disabled — it refuses to load without a configured gateway-url, which
will exist once b2bcall-fs-config is built
- entrypoint.sh rotates the Event Socket password away from the 'ClueCon'
default at container runtime (never baked into the image); fails loudly if
ESL_PASSWORD is unset
- port 8021 not published to the host; only reachable from other containers
on the compose network
- found and fixed: freeswitch-conf-vanilla is a Recommends (not a Depends)
of freeswitch-meta-vanilla, so --no-install-recommends silently produced
an empty /etc/freeswitch and a crash loop
- verified end-to-end: fs_cli status via ESL with the custom password,
default password rejected, expected modules loaded, healthcheck green,
~44MB RAM usage
- docs/FREESWITCH.md, docs/NETWORK_ARCHITECTURE.md (network_mode decision
deferred until a real SIP trunk exists)
- POST /auth/login, /auth/refresh, /auth/logout, /auth/select-tenant,
/auth/change-password, GET /auth/tenants — wired to packages/auth
- JwtAuthGuard + DomainExceptionFilter (401/403 without leaking internals)
- LoginRateLimitGuard: Redis-backed 5/min per IP and per email (agente.md
secao 149), safe across multiple API instances
- helmet + restrictive cors (deny-by-default) + global rate limit
- GET /health, /health/live, /health/ready checking Postgres and Redis
- changePassword() added to packages/auth for the mustChangePassword flow
- fixed REDIS_HOST/POSTGRES_HOST docker-compose-only hostnames not
resolving from the host process; added REDIS_URL for host-side use
- verified end-to-end with curl: login, wrong password / unknown email
(same generic error), authenticated route, missing token, refresh
rotation, logout revocation, and the 429 rate limit kicking in after 5
attempts
- packages/auth: Argon2id password hashing, JWT access tokens (jose),
opaque refresh tokens with rotation, generic error messages (no
user-enumeration via timing or message differences)
- roles/permissions/role_permissions/user_roles/sessions/audit_logs schema
(agente.md secoes 142-150); RBAC scope PLATFORM vs TENANT
- withUserContext(): narrow RLS exception so a user can discover their own
tenant_memberships before a tenant is chosen (login flow)
- userHasPermission()/isPlatformUser(): explicit service-layer RBAC checks
(roles/permissions tables are not RLS-protected — documented why in
docs/AUTHENTICATION.md)
- seed: permission catalog, 4 system roles, initial Platform Super Admin
(password written once to FIRST_LOGIN.txt, 600, outside Git)
- automated end-to-end test: login, RBAC check, refresh rotation, logout
- users + tenant_memberships tables (tenant-scoped)
- RLS policy on tenant_memberships using set_config('app.current_tenant_id', ...)
- withTenantContext() helper for transaction-scoped tenant context
- separate non-superuser app role (b2bcall_app): the default Docker postgres
user is SUPERUSER and always bypasses RLS even with FORCE, so the app must
never connect through the migration/owner role. Documented in
docs/TENANT_ISOLATION.md.
- automated isolation test proving tenant A never sees tenant B's data