Multi-tenant CTI middleware connecting Asterisk/FreePBX to CRMs — click-to-call, screen pops, automated call logging. Design rationale, event model, and roadmap: cti-architecture.md.
| Doc | What's in it |
|---|---|
| docs/INSTALL.md | Step-by-step installation: infra, env, PBX prep (lab + production FreePBX), onboarding, WebRTC, reverse connector, container deploy, go-live runbook |
| docs/HOW-TO-USE.md | Task-oriented how-tos for every feature (curl/WS examples) + troubleshooting |
| docs/TESTING.md | Manual test & validation scenarios, negative cases, and end-to-end user-flow use-cases |
| docs/FEATURES.md | Feature catalog — how each works and its endpoints |
/docs (Swagger UI) |
Live, interactive API reference with all four auth schemes — served by the running app |
| docs/postman/ | Postman collection: every endpoint with request + example responses |
| docs/adr/ | Architecture Decision Records 0001–0010 (AMI-over-ARI, correlation engine, multi-tenancy, reverse connector, deployment, WebRTC/CRM expansion, …) |
| docs/ROADMAP.md | Roadmap with per-phase status; only Phase 11 (ARI) remains |
| cti-architecture.md | Architecture explainer + sequence diagrams (with an as-built note) |
Built and validated through Phase 11 (all roadmap phases complete). What's implemented:
- Core (P1–2): hand-rolled AMI connector, Linkedid call-state correlation → normalized
call.*events, multi-tenant registry (shared-PBX routing, encrypted creds, scoped API keys), generic signed webhooks over durable BullMQ. - CRMs (P3–4, P10): Zoho PhoneBridge, Salesforce Open CTI, HubSpot, Microsoft Dynamics 365 — a tenant can enable several; logging fans out to each.
- Productization (P5): reverse on-prem connector (no inbound firewall holes), recording proxy with signed URLs, agent presence, admin API + dashboard with hot-reload.
- Hardening (P6–9): 60-test Jest suite + CI, TypeORM migrations, Redis-backed call-state +
CoreShowChannelsresync, per-tenant originate rate-limit, graceful shutdown, structured JSON logs, Prometheus/metrics, readiness/liveness probes, dead-letter alerting + retry UI, multi-stage Docker image + Caddy TLS/wss reverse proxy, recordings pulled over the reverse tunnel. - WebRTC softphone (P10): in-browser audio via self-hosted JsSIP (real two-way audio needs a WebRTC-configured PBX).
- Advanced telephony (P11): ARI connector (Stasis → same normalized events), in-call coaching (spy/whisper/barge), queue/ACD wallboard, CRM-driven IVR routing.
Truly pending (operational, not code): real Zoho/Salesforce/HubSpot/Dynamics org credentials (see INSTALL §11); a WebRTC-enabled Asterisk for live browser media; and a Stasis/queue-configured PBX for live ARI coaching audio + queue wallboard stats.
- Reverse connector — a PBX connection with
mode: reverseis passive: the customer runs scripts/connector-agent.mjs (dependency-free, Node ≥ 21) withCTI_URL+CONNECTOR_TOKEN; it dials OUT towss://cti/connector-wsand tunnels the local AMI socket (and, on a second channel, serves recording files). AMI credentials never leave the cloud registry — login happens server-side over the tunnel. Heartbeats (15s ping) reap dead tunnels. - Recordings —
call.endedcarriesrecordingUrl, a 15-minute signed capability URL served byGET /v1/recordings/:token(basename-only, traversal-proof). Direct connections readRECORDINGS_BASE_DIR; reverse connections pull the file from the on-prem agent over the tunnel — no shared mount needed. - Presence —
agent.state(RINGING/INUSE/NOT_INUSEfrom call lifecycle,UNAVAILABLEfrom AMIDeviceStateChange) broadcast to the tenant's softphone sockets and queryable atGET /v1/agents/state. - Admin —
/admindashboard (connections, tenants, active calls, queue health, dead-letter retry) overX-Admin-Keyendpoints:GET /admin/overview,POST /admin/{pbx-connections,tenants,agents,integrations}(generated keys/tokens returned once),POST /admin/dead-letters/:queue/:jobId/retry, andPOST /admin/reload(diff-restarts only changed connections).
Salesforce is client-side: we host the softphone page (public/softphone.html), Salesforce embeds it via the Call Center definition (public/callcenter-definition.xml, served at /softphone/callcenter-definition.xml).
- Agent session:
POST /v1/softphone/login {ext}(tenant API key) returns a short-lived HS256 token (SOFTPHONE_JWT_SECRET). - Live events: the page connects to
ws(s)://host/softphone-ws?token=…; the gateway pushes only that agent'scall.*events. On inbound ringing the page callssforce.opencti.searchAndScreenPop(when embedded) — Salesforce matches the number and pops the record. - Click-to-dial: Open CTI's
onClickToDial(or the page's dial pad) POSTs/v1/softphone/originatewith the agent token → agent-leg-first originate. - Call logging:
call.endedfor Salesforce-enabled tenants flows through the durablesalesforce-deliveryqueue; the processor exchanges the org's connected-app refresh token for an access token and creates aTask(TaskSubtype Call, duration, disposition) owned by the mapped user (Agent.crmRefs.salesforce). - Lab testing:
node scripts/mock-salesforce.mjs 4200mimics the OAuth + Task endpoints; the seed points tenant-b at it. In production, import the Call Center XML (Setup → Call Center), replaceCTI_BASE_URL, and assign users.
Per-tenant CrmIntegration rows (type zoho) hold the DC/base URLs/client id in config and the client secret, refresh token, and callback token encrypted in secretsEnc. Flow:
- Events out: normalized
call.*events for Zoho-enabled tenants are mirrored into a durablezoho-deliveryqueue; the processor exchanges the tenant's refresh token for a cached access token and POSTs RINGING (creates the call → Zoho pops the matched contact for the mapped user) then PUTs ANSWERED/ENDED (Zoho logs the activity). Agent ↔ Zoho user mapping lives inAgent.crmRefs.zoho. - Click-to-call in: Zoho's dial-icon callback POSTs to
/v1/integrations/zoho/:tenantSlug/click-to-call(X-Zoho-Token= per-integration callback token); the Zoho user resolves to an agent extension and a normal agent-leg-first originate follows. - Lab testing:
node scripts/mock-zoho.mjs 4100mimics the token + PhoneBridge endpoints; the seed points tenant-a at it.⚠️ Endpoint paths/payloads follow PhoneBridge v3 shape but must be reconciled with Zoho's partner docs once registration is approved — changes are confined tozoho-client.ts+ the mock.
# 1. Lab Asterisk (AMI on 127.0.0.1:5038)
cd ../Multi-Tenant-Asterisk-PBX && docker compose up -d
# 2. CTI infra (Postgres :5433, Redis :6380)
docker compose up -d
# 3. Build, seed two lab tenants (prints their API keys ONCE), run
npm install && npm run build
npm run seed
npm start
# 4. Example per-tenant webhook consumers
WEBHOOK_SECRET=receiver-a-secret node scripts/webhook-receiver.mjs 4000
WEBHOOK_SECRET=receiver-b-secret node scripts/webhook-receiver.mjs 4001The seed creates tenant-a (extensions 1XXX, contexts tenant-a-*) and tenant-b (2XXX, tenant-b-*) sharing the single lab Asterisk — the same shape as a hosted multi-tenant PBX. A production FreePBX is one more PbxConnection row with originateChannelTemplate = PJSIP/{ext}.
| Endpoint | Auth | Purpose |
|---|---|---|
GET /health |
none | per-PBX-connection status |
POST /v1/calls/originate {agentExt, number} |
X-API-Key (tenant) |
click-to-call; agent must belong to the key's tenant |
GET /v1/calls |
X-API-Key (tenant) |
in-flight calls of that tenant |
curl -s -X POST http://127.0.0.1:3000/v1/calls/originate \
-H "X-API-Key: $TENANT_A_KEY" -H 'Content-Type: application/json' \
-d '{"agentExt": "1001", "number": "1000"}'Normalized events (call.ringing, call.answered, call.ended) are enqueued in BullMQ (4 attempts, exponential backoff; failed jobs remain in Redis as dead letters) and POSTed to each tenant's webhookUrl:
{ "id": "uuid", "type": "call.ended", "tenantId": "tenant-a", "occurredAt": "…", "data": { … } }Headers: X-CTI-Timestamp (epoch ms) and X-CTI-Signature = hex HMAC-SHA256 of `${timestamp}.${rawBody}` with the tenant's webhook secret. Reject skew > 5 min; compare in constant time. Reference consumer: scripts/webhook-receiver.mjs.
npm test # 60 unit + integration tests, no live infra
docker compose -f docker-compose.full.yml up -d --build # app + pg + redis + Caddy (TLS)
curl -k https://localhost:8443/health # HTTPS via CaddyObservability: GET /metrics (Prometheus), GET /health/live + /health/ready (probes), structured JSON logs (LOG_FORMAT=json). See INSTALL §10–12 for production deployment, secrets/KMS, and the go-live checklist.
Earlier "deferred" items (TypeORM migrations, Redis-backed call state + resync, admin CRUD/hot-reload, recordings over the tunnel) are all implemented — see the Status section. Registry changes now apply via
POST /admin/reload(no restart).