@webex/contact-center 3.12.0-next.157 → 3.12.0-next.159
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ai-docs/contact-center-spec.md +3 -0
- package/dist/cc.js +10 -2
- package/dist/cc.js.map +1 -1
- package/dist/services/ApiAiAssistant.js +18 -0
- package/dist/services/ApiAiAssistant.js.map +1 -1
- package/dist/services/WebCallingService.js +10 -2
- package/dist/services/WebCallingService.js.map +1 -1
- package/dist/services/constants.js +11 -1
- package/dist/services/constants.js.map +1 -1
- package/dist/services/core/Utils.js +21 -1
- package/dist/services/core/Utils.js.map +1 -1
- package/dist/services/core/aqm-reqs.js +7 -15
- package/dist/services/core/aqm-reqs.js.map +1 -1
- package/dist/services/core/constants.js +9 -1
- package/dist/services/core/constants.js.map +1 -1
- package/dist/services/core/websocket/WebSocketManager.js +17 -1
- package/dist/services/core/websocket/WebSocketManager.js.map +1 -1
- package/dist/types/services/ApiAiAssistant.d.ts +9 -0
- package/dist/types/services/constants.d.ts +9 -0
- package/dist/types/services/core/Utils.d.ts +8 -0
- package/dist/types/services/core/constants.d.ts +7 -0
- package/dist/webex.js +1 -1
- package/package.json +2 -2
- package/src/cc.ts +15 -2
- package/src/services/ApiAiAssistant.ts +24 -0
- package/src/services/WebCallingService.ts +16 -2
- package/src/services/ai-docs/services-spec.md +8 -1
- package/src/services/constants.ts +10 -0
- package/src/services/core/Utils.ts +24 -0
- package/src/services/core/ai-docs/core-spec.md +23 -13
- package/src/services/core/aqm-reqs.ts +6 -15
- package/src/services/core/constants.ts +8 -0
- package/src/services/core/websocket/WebSocketManager.ts +26 -2
- package/test/unit/spec/cc.ts +21 -0
- package/test/unit/spec/services/ApiAiAssistant.ts +13 -0
- package/test/unit/spec/services/WebCallingService.ts +38 -0
- package/test/unit/spec/services/core/Utils.ts +22 -0
- package/test/unit/spec/services/core/aqm-reqs.ts +29 -0
- package/test/unit/spec/services/core/websocket/WebSocketManager.ts +64 -14
- package/umd/contact-center.min.js +2 -2
- package/umd/contact-center.min.js.map +1 -1
|
@@ -67,6 +67,18 @@ export class ApiAIAssistant {
|
|
|
67
67
|
this.aiFeature = aiFeature;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Validates a caller-supplied identifier (agentId/interactionId) before it is used to
|
|
72
|
+
* construct a request. Accepts a non-empty alphanumeric identifier that may contain
|
|
73
|
+
* hyphens or underscores.
|
|
74
|
+
* @param value - the identifier to validate
|
|
75
|
+
* @returns {boolean} true when the identifier is a non-empty, well-formed string
|
|
76
|
+
* @private
|
|
77
|
+
*/
|
|
78
|
+
private isValidTranscriptIdentifier(value: unknown): boolean {
|
|
79
|
+
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
70
82
|
private async sendWellnessBreakAction(
|
|
71
83
|
action: WellnessBreakUserAction,
|
|
72
84
|
method: string
|
|
@@ -625,6 +637,18 @@ export class ApiAIAssistant {
|
|
|
625
637
|
throw detailedError;
|
|
626
638
|
}
|
|
627
639
|
|
|
640
|
+
if (
|
|
641
|
+
!this.isValidTranscriptIdentifier(agentId) ||
|
|
642
|
+
!this.isValidTranscriptIdentifier(interactionId)
|
|
643
|
+
) {
|
|
644
|
+
const {error: detailedError} = getErrorDetails(
|
|
645
|
+
new Error('INVALID_AGENT_ID_OR_INTERACTION_ID'),
|
|
646
|
+
METHODS.FETCH_HISTORIC_TRANSCRIPTS,
|
|
647
|
+
CC_FILE
|
|
648
|
+
);
|
|
649
|
+
throw detailedError;
|
|
650
|
+
}
|
|
651
|
+
|
|
628
652
|
try {
|
|
629
653
|
const baseUrl = this.getBaseUrl();
|
|
630
654
|
const orgId = this.webex.credentials.getOrgId();
|
|
@@ -15,11 +15,13 @@ import {TIMEOUT_DURATION, WEB_CALLING_SERVICE_FILE} from '../constants';
|
|
|
15
15
|
import LoggerProxy from '../logger-proxy';
|
|
16
16
|
import {
|
|
17
17
|
DEFAULT_RTMS_DOMAIN,
|
|
18
|
+
ALLOWED_RTMS_DOMAIN,
|
|
18
19
|
POST_AUTH,
|
|
19
20
|
WCC_CALLING_RTMS_DOMAIN,
|
|
20
21
|
DEREGISTER_WEBCALLING_LINE_MSG,
|
|
21
22
|
METHODS,
|
|
22
23
|
} from './constants';
|
|
24
|
+
import {isAllowedUrlHost} from './core/Utils';
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* WebCallingService provides WebRTC calling functionality for Contact Center agents.
|
|
@@ -141,9 +143,21 @@ export default class WebCallingService extends EventEmitter {
|
|
|
141
143
|
const rtmsURL = this.webex.internal.services.get(WCC_CALLING_RTMS_DOMAIN);
|
|
142
144
|
|
|
143
145
|
try {
|
|
144
|
-
const
|
|
146
|
+
const hostname = new URL(rtmsURL).hostname.toLowerCase();
|
|
147
|
+
|
|
148
|
+
if (!isAllowedUrlHost(rtmsURL, [ALLOWED_RTMS_DOMAIN])) {
|
|
149
|
+
LoggerProxy.error(
|
|
150
|
+
`Non-allow-listed RTMS host from u2c catalogue: ${hostname} so falling back to default domain`,
|
|
151
|
+
{
|
|
152
|
+
module: WEB_CALLING_SERVICE_FILE,
|
|
153
|
+
method: METHODS.GET_RTMS_DOMAIN,
|
|
154
|
+
}
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return DEFAULT_RTMS_DOMAIN;
|
|
158
|
+
}
|
|
145
159
|
|
|
146
|
-
return
|
|
160
|
+
return hostname;
|
|
147
161
|
} catch (error) {
|
|
148
162
|
LoggerProxy.error(
|
|
149
163
|
`Invalid URL from u2c catalogue: ${rtmsURL} so falling back to default domain`,
|
|
@@ -70,7 +70,7 @@ Each service folder contains its own `ai-docs/` with detailed documentation. **A
|
|
|
70
70
|
|
|
71
71
|
**Paginated data services** (AddressBook, Queue, EntryPoint) do not have dedicated ai-docs. Read their source files directly — they follow shared REST/pagination/caching patterns documented in [`ai-docs/patterns/typescript-patterns.md`](../../../ai-docs/patterns/typescript-patterns.md). `UserPreference` is also a direct REST service, but it has CRUD semantics and does not use their PageCache pattern.
|
|
72
72
|
|
|
73
|
-
**WebCallingService** also has no dedicated ai-docs, but it follows a different pattern: EventEmitter-based call lifecycle orchestration around `@webex/calling` (`createClient`, line registration/deregistration, `ICall` events), `callTaskMap` tracking, and async registration flows with timeout handling. Read [`WebCallingService.ts`](../WebCallingService.ts) directly when changing browser calling behavior.
|
|
73
|
+
**WebCallingService** also has no dedicated ai-docs, but it follows a different pattern: EventEmitter-based call lifecycle orchestration around `@webex/calling` (`createClient`, line registration/deregistration, `ICall` events), `callTaskMap` tracking, and async registration flows with timeout handling. Before registering the calling line it resolves the RTMS domain from the u2c service catalog through `getRTMSDomain()`, which trusts the catalog host only when it is on the `ALLOWED_RTMS_DOMAIN` allow-list and otherwise falls back to `DEFAULT_RTMS_DOMAIN`. Read [`WebCallingService.ts`](../WebCallingService.ts) directly when changing browser calling behavior.
|
|
74
74
|
|
|
75
75
|
The `ContactCenter` plugin class (`cc.ts`) is the **only public entry point**. It delegates all backend work to the services layer:
|
|
76
76
|
|
|
@@ -212,6 +212,8 @@ Use [`constants.ts`](../constants.ts) as the canonical source for service-level
|
|
|
212
212
|
|
|
213
213
|
- `DEFAULT_RTMS_DOMAIN`, `WCC_CALLING_RTMS_DOMAIN` — RTMS/WebRTC domain constants
|
|
214
214
|
|
|
215
|
+
- `ALLOWED_RTMS_DOMAIN` — trusted RTMS domain suffix (`rtmsprod.net`); a catalog-provided RTMS host is used only when its hostname equals or is a subdomain of this value, otherwise `DEFAULT_RTMS_DOMAIN` is used
|
|
216
|
+
|
|
215
217
|
- `METHODS` — method name constants used by `WebCallingService`
|
|
216
218
|
|
|
217
219
|
## Key Files (source of truth)
|
|
@@ -267,6 +269,8 @@ ContactCenter READY callback
|
|
|
267
269
|
| SERVICES-R-006 | Treat Services composition as unconditionally created by the ContactCenter READY callback; Services owns no rollout or feature-flag decision. | Capability flags belong to the consuming config/task/calling collaborators, so the composition root must not silently gate construction. | `src/services/index.ts`, `src/cc.ts` | `test/unit/spec/cc.ts` | None; rollout applicability is explicitly N/A for Services. | PRESENT |
|
|
268
270
|
| SERVICES-R-007 | Existing Queue and EntryPoint list methods must apply inbound, active, telephony, profile/agent-view, and `name,ASC` defaults while retaining their established parameter and full-record response types. Caller-supplied existing filter, sort, or profile inputs override defaults. Queue must also treat `sortOrder` without `sortBy` as a name sort and bypass the simple-page cache. | Defaults on the established methods let thin consumers request lists without a parallel API, while existing parameters preserve specialized behavior for other consumers and full-record types remain truthful because no field projection is requested. | `src/services/Queue.ts`, `src/services/EntryPoint.ts`, `src/types.ts` | `test/unit/spec/services/Queue.ts`, `test/unit/spec/services/EntryPoint.ts`, `test/unit/spec/cc.ts` | The backend honors the view flags and combined CMS sort value. | PRESENT |
|
|
269
271
|
| SERVICES-R-008 | ApiAIAssistant wellness requests must accept no public identity or context-mutating input, read effective enablement and non-empty SDK-owned organization/agent/session context from a read-only provider supplied at construction, then POST the exact `WellnessBreakAction` custom-event body directly to `/event`; only HTTP 202 resolves successfully. Public action unions derive from exported `as const` action-value objects. | SDK-owned live context prevents callers from supplying stale/different login identities to action calls, one value/type source prevents wire drift, and direct HTTP matches the AI event endpoint rather than AQM completion semantics. | `src/services/ApiAiAssistant.ts`, `src/types.ts`, `src/index.ts`, `src/cc.ts` | `test/unit/spec/services/ApiAiAssistant.ts`, `test/unit/spec/cc.ts` | None. | PRESENT |
|
|
272
|
+
| SERVICES-R-009 | `WebCallingService.getRTMSDomain()` returns a catalog-provided RTMS hostname only when it equals `ALLOWED_RTMS_DOMAIN` (`rtmsprod.net`) or is a subdomain of it; a parseable but non-allow-listed host, or a catalog value that is not a valid URL, falls back to `DEFAULT_RTMS_DOMAIN`. The rejected host is logged as a diagnostic before fallback. | The u2c service catalog is an untrusted input at the calling trust boundary; allow-listing the RTMS host prevents WebRTC registration against an attacker-controlled domain. | `src/services/WebCallingService.ts`, `src/services/constants.ts` | `test/unit/spec/services/WebCallingService.ts` | The `@webex/calling` client accepts the resolved domain string. | PRESENT |
|
|
273
|
+
| SERVICES-R-010 | `ApiAIAssistant.fetchHistoricTranscripts(agentId: string, interactionId: string): Promise<HistoricTranscriptsResponse>` validates both caller-supplied identifiers before building the authenticated request, accepting only non-empty strings matching the identifier format `^[A-Za-z0-9_-]{1,128}$`. Invalid inputs reject with a typed error and no request is sent. | `agentId`/`interactionId` are caller-controlled inputs on an authenticated request boundary; validating them before use rejects malformed identifiers instead of forwarding them to the backend. | `src/services/ApiAiAssistant.ts` | `test/unit/spec/services/ApiAiAssistant.ts` | Real-time transcription feature flag must already be enabled; format validation runs after that gate. | PRESENT |
|
|
270
274
|
|
|
271
275
|
## Design Overview
|
|
272
276
|
`Services` is a singleton composition root for transport-facing capabilities only. It constructs two `WebSocketManager` instances (primary Contact Center and RTD), creates `AqmReqs` on the primary manager, then creates config, agent, contact, dialer, and ConnectionService collaborators.
|
|
@@ -415,6 +419,7 @@ classDiagram
|
|
|
415
419
|
- ApiAIAssistant, TaskManager, and UserPreference are READY-time ContactCenter collaborators, not fields constructed by Services.
|
|
416
420
|
- Authentication is inherited from the host SDK through Core/WebexRequest; Services owns no credential lifecycle.
|
|
417
421
|
- Rollout applicability is N/A for the Services composition root: it is created at READY and does not evaluate a feature flag.
|
|
422
|
+
- Untrusted external inputs are validated at the service trust boundary: catalog-provided RTMS hosts must pass the `ALLOWED_RTMS_DOMAIN` allow-list before use, and caller-supplied `agentId`/`interactionId` must match the accepted identifier format before an authenticated transcript request is built.
|
|
418
423
|
- ApiAIAssistant evaluates the current effective wellness flag and identities through its constructor-supplied read-only provider; the public object exposes no context mutator, and the Services singleton itself remains unconditionally composed.
|
|
419
424
|
|
|
420
425
|
## Concurrency & Reactive Flow
|
|
@@ -489,6 +494,8 @@ Unit tests mirror module paths under `test/unit/spec/services`. Preserve positiv
|
|
|
489
494
|
| `SERVICES-R-006` | `test/unit/spec/cc.ts` | None. |
|
|
490
495
|
| `SERVICES-R-007` | `test/unit/spec/services/Queue.ts`, `test/unit/spec/services/EntryPoint.ts`, `test/unit/spec/cc.ts` | None. |
|
|
491
496
|
| `SERVICES-R-008` | `test/unit/spec/services/ApiAiAssistant.ts` | None. |
|
|
497
|
+
| `SERVICES-R-009` | `test/unit/spec/services/WebCallingService.ts` | Cover allowed host, subdomain, non-allow-listed host, and unparseable catalog value fallback. |
|
|
498
|
+
| `SERVICES-R-010` | `test/unit/spec/services/ApiAiAssistant.ts` | Cover valid identifiers plus empty, over-length, and disallowed-character rejections. |
|
|
492
499
|
|
|
493
500
|
## Traceability
|
|
494
501
|
- Repo architecture: `../../../ai-docs/ARCHITECTURE.md` · Registry: `../../../ai-docs/SPEC_INDEX.md`
|
|
@@ -32,6 +32,16 @@ export const WCC_CALLING_RTMS_DOMAIN = 'wcc-calling-rtms-domain';
|
|
|
32
32
|
*/
|
|
33
33
|
export const DEFAULT_RTMS_DOMAIN = 'rtw.prod-us1.rtmsprod.net';
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Trusted domain suffix for RTMS hosts returned by the u2c service catalog. A catalog-provided
|
|
37
|
+
* RTMS host is only used when its hostname is, or is a subdomain of, this domain; otherwise
|
|
38
|
+
* `DEFAULT_RTMS_DOMAIN` is used.
|
|
39
|
+
* @type {string}
|
|
40
|
+
* @public
|
|
41
|
+
* @ignore
|
|
42
|
+
*/
|
|
43
|
+
export const ALLOWED_RTMS_DOMAIN = 'rtmsprod.net';
|
|
44
|
+
|
|
35
45
|
/**
|
|
36
46
|
* Timeout in milliseconds for WebSocket events.
|
|
37
47
|
* @type {number}
|
|
@@ -103,6 +103,30 @@ export const isValidDialNumber = (
|
|
|
103
103
|
});
|
|
104
104
|
};
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Checks whether a URL's hostname is, or is a subdomain of, one of the allowed domains.
|
|
108
|
+
*
|
|
109
|
+
* @param url - The candidate URL to validate
|
|
110
|
+
* @param allowedDomains - Domains (or domain suffixes) the URL's hostname must match
|
|
111
|
+
* @returns true when the URL parses and its hostname matches an allowed domain, false otherwise
|
|
112
|
+
*/
|
|
113
|
+
export const isAllowedUrlHost = (
|
|
114
|
+
url: string | undefined | null,
|
|
115
|
+
allowedDomains: string[]
|
|
116
|
+
): boolean => {
|
|
117
|
+
if (!url) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const hostname = new URL(url).hostname.toLowerCase();
|
|
123
|
+
|
|
124
|
+
return allowedDomains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
106
130
|
export const getStationLoginErrorData = (failure: Failure, loginOption: LoginOption) => {
|
|
107
131
|
let duplicateLocationMessage = 'This value is already in use';
|
|
108
132
|
|
|
@@ -37,13 +37,13 @@ The Core service provides the foundational infrastructure layer that all other s
|
|
|
37
37
|
|
|
38
38
|
| Component | File | Description |
|
|
39
39
|
|---|---|---|
|
|
40
|
-
| `WebSocketManager` | [`WebSocketManager.ts`](../websocket/WebSocketManager.ts) | Manages the WebSocket connection lifecycle including initialization, message dispatch, and graceful shutdown. Emits `message` events for incoming data and `socketClose` when the connection drops while reconnect is allowed. |
|
|
40
|
+
| `WebSocketManager` | [`WebSocketManager.ts`](../websocket/WebSocketManager.ts) | Manages the WebSocket connection lifecycle including initialization, message dispatch, and graceful shutdown. Validates the subscribe/register `webSocketUrl` against the RoutingNotifs allow-list before opening a socket. Emits `message` events for incoming data and `socketClose` when the connection drops while reconnect is allowed. |
|
|
41
41
|
| `ConnectionService` | [`connection-service.ts`](../websocket/connection-service.ts) | Orchestrates reconnection logic and keepalive heartbeats on top of `WebSocketManager`. Detects connection loss/recovery and emits `connectionLost` details; ContactCenter owns any `silentRelogin()` policy. |
|
|
42
42
|
| `WebexRequest` | [`WebexRequest.ts`](../WebexRequest.ts) | Singleton HTTP client that forwards service/resource/method/body options to the authenticated host request API and provides `uploadLogs` diagnostics. |
|
|
43
43
|
| `AqmReqs` | [`aqm-reqs.ts`](../aqm-reqs.ts) | Factory for creating request methods that send HTTP requests and wait for correlated WebSocket notifications (success or failure). Used by routing and task services to implement their API methods. |
|
|
44
44
|
| `Utils` | [`Utils.ts`](../Utils.ts) | Shared utility functions including `getErrorDetails()` for standardized error handling, `generateTaskErrorObject()` for task-specific errors, and `createErrDetailsObject()` for constructing error detail objects. |
|
|
45
45
|
| `Err` | [`Err.ts`](../Err.ts) | Error class definitions. `Err.Details` carries structured error metadata (status, type, trackingId) for consistent error propagation. |
|
|
46
|
-
| `constants` | [`constants.ts`](../constants.ts) | Timeout values, interval durations, participant types, interaction states,
|
|
46
|
+
| `constants` | [`constants.ts`](../constants.ts) | Timeout values, interval durations, participant types, interaction states, method name constants, and the `ALLOWED_ROUTING_NOTIFS_DOMAINS` WebSocket host allow-list used throughout the core layer. Any new constants for core should be defined here. |
|
|
47
47
|
|
|
48
48
|
## Purpose / Responsibility
|
|
49
49
|
Own authenticated HTTP, realtime WebSocket lifecycle, AQM request correlation, reconnect/keepalive behavior, and shared error normalization.
|
|
@@ -259,13 +259,14 @@ connectionService.on('connectionLost', (details: ConnectionLostDetails) => {
|
|
|
259
259
|
| CORE-R-004 | ConnectionService must emit transport-state details and retry `initWebSocket({body, resource})`; ContactCenter owns relogin policy. | Separating transport detection from agent recovery prevents Core from mutating package-level session state. | `src/services/core/websocket/connection-service.ts` | `test/unit/spec/services/core/websocket/connection-service.ts` | None; source and test evidence rechecked during the 2026-07-09 remediation; independent document revalidation pending. | PRESENT |
|
|
260
260
|
| CORE-R-005 | The keepalive worker must use the configured 4-second interval and 16-second close-socket timeout; AQM defaults to 20 seconds unless disabled/overridden. | Accurate timing is required for predictable recovery and request failure behavior. | `src/services/core/constants.ts` | `test/unit/spec/services/core/websocket/WebSocketManager.ts` | None; source and test evidence rechecked during the 2026-07-09 remediation; independent document revalidation pending. | PRESENT |
|
|
261
261
|
| CORE-R-006 | Treat Core timeout, keepalive, and recovery constants as fixed behavior controls, not rollout flags; Core owns no feature-gate evaluation. | Conflating operational constants with rollout policy could disable transport or correlation paths unexpectedly. | `src/services/core/constants.ts`, `src/services/index.ts` | `test/unit/spec/services/core/websocket/WebSocketManager.ts` | None; rollout applicability is explicitly N/A for Core. | PRESENT |
|
|
262
|
-
| CORE-R-007 | When an AQM request sets `redactSensitiveLogs`,
|
|
262
|
+
| CORE-R-007 | AQM routing-failure logging never emits the raw routing message payload or error verbatim; the failure-bind path logs only a fixed redacted diagnostic regardless of `redactSensitiveLogs`. When an AQM request additionally sets `redactSensitiveLogs`, timeout logs must also omit the request URL, bind key, and response while preserving settlement and cleanup behavior. | Dynamic request paths and routing messages can contain participant identifiers or phone numbers that must not enter diagnostic logs. | `src/services/core/aqm-reqs.ts`, `src/services/core/types.ts` | `test/unit/spec/services/core/aqm-reqs.ts` | The timeout flag is internal and opt-in; ordinary AQM logging remains backward compatible while routing-failure logging is unconditionally redacted. | PRESENT |
|
|
263
|
+
| CORE-R-008 | `WebSocketManager` opens a socket only for a subscribe/register `webSocketUrl` that parses as a `wss` URL whose host equals, or is a subdomain of, an entry in `ALLOWED_ROUTING_NOTIFS_DOMAINS`; a malformed, non-`wss`, or non-allow-listed URL is rejected with an error and no socket is opened. | A server-controlled subscribe response could otherwise direct the realtime connection to an untrusted or non-secure endpoint. | `src/services/core/websocket/WebSocketManager.ts`, `src/services/core/constants.ts` | `test/unit/spec/services/core/websocket/WebSocketManager.ts` | The allow-list is a fixed domain set; subdomain matching is exact suffix matching on the parsed hostname. | PRESENT |
|
|
263
264
|
|
|
264
265
|
## Design Overview
|
|
265
266
|
Core separates four responsibilities:
|
|
266
267
|
|
|
267
268
|
1. `WebexRequest` wraps the authenticated host request API and service-catalog routing.
|
|
268
|
-
2. `WebSocketManager` registers a subscription using both `body` and `resource`,
|
|
269
|
+
2. `WebSocketManager` registers a subscription using both `body` and `resource`, validates the returned `webSocketUrl` against the `ALLOWED_ROUTING_NOTIFS_DOMAINS` allow-list before connecting the socket, owns the keepalive worker, and emits raw messages/socket lifecycle events.
|
|
269
270
|
3. `AqmReqs` registers bind matchers on the primary WebSocket, sends HTTP through WebexRequest, and settles requests only from matching notifications, HTTP failure, or timeout.
|
|
270
271
|
4. `ConnectionService` observes message/socket liveness, emits connection-state details, and retries socket initialization. ContactCenter listens to those details and owns optional silent relogin.
|
|
271
272
|
|
|
@@ -294,7 +295,7 @@ const setState = aqmReqs.req((p: {data: Agent.StateChange}) => ({
|
|
|
294
295
|
await setState({data: stateChangePayload});
|
|
295
296
|
```
|
|
296
297
|
|
|
297
|
-
`CLOSE_SOCKET_TIMEOUT` is 16000 ms. `CONNECTIVITY_CHECK_INTERVAL` drives reconnect attempts separately. `TIMEOUT_REQ` is the 20000 ms default AQM timeout; `WEBSOCKET_EVENT_TIMEOUT` is not the active AqmReqs default. A request configuration may set internal `redactSensitiveLogs: true
|
|
298
|
+
`CLOSE_SOCKET_TIMEOUT` is 16000 ms. `CONNECTIVITY_CHECK_INTERVAL` drives reconnect attempts separately. `TIMEOUT_REQ` is the 20000 ms default AQM timeout; `WEBSOCKET_EVENT_TIMEOUT` is not the active AqmReqs default. Routing-failure diagnostics are always redacted to a fixed message that omits the raw payload and error. A request configuration may additionally set internal `redactSensitiveLogs: true` to redact timeout diagnostics (request URL, bind key, and response); neither redaction changes correlation, errors, timing, or cleanup.
|
|
298
299
|
|
|
299
300
|
## Data Flow
|
|
300
301
|
```mermaid
|
|
@@ -365,12 +366,17 @@ sequenceDiagram
|
|
|
365
366
|
Caller->>WSM: initWebSocket({body, resource})
|
|
366
367
|
WSM->>Host: register(body, resource)
|
|
367
368
|
Host-->>WSM: WebSocket URL/subscription
|
|
368
|
-
WSM->>
|
|
369
|
-
alt
|
|
370
|
-
WS
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
369
|
+
WSM->>WSM: validate webSocketUrl against wss + allow-list
|
|
370
|
+
alt allow-listed wss URL
|
|
371
|
+
WSM->>WS: connect()
|
|
372
|
+
alt welcome
|
|
373
|
+
WS-->>WSM: Welcome event
|
|
374
|
+
WSM-->>Caller: WelcomeResponse
|
|
375
|
+
else connect failure
|
|
376
|
+
WSM-->>Caller: throw error
|
|
377
|
+
end
|
|
378
|
+
else malformed / non-wss / non-allow-listed URL
|
|
379
|
+
WSM-->>Caller: throw error (no socket opened)
|
|
374
380
|
end
|
|
375
381
|
```
|
|
376
382
|
|
|
@@ -448,6 +454,8 @@ WebSocketManager owns socket/welcome/worker state. AqmReqs owns pending success/
|
|
|
448
454
|
|
|
449
455
|
## Business Rules & Invariants
|
|
450
456
|
- Core must preserve its typed public/event contracts and must not invent backend states or responses. Enforced in `src/services/core/WebexRequest.ts`.
|
|
457
|
+
- A subscribe/register `webSocketUrl` is trusted only when it is a `wss` URL on the `ALLOWED_ROUTING_NOTIFS_DOMAINS` allow-list; otherwise `WebSocketManager` throws and never opens a socket. Enforced in `src/services/core/websocket/WebSocketManager.ts`.
|
|
458
|
+
- AQM routing-failure logs never contain the raw routing payload or error verbatim. Enforced in `src/services/core/aqm-reqs.ts`.
|
|
451
459
|
- Rollout applicability is N/A for Core: keepalive, timeout, and reconnect constants control behavior, while Services constructs Core without a feature gate.
|
|
452
460
|
|
|
453
461
|
## Concurrency & Reactive Flow
|
|
@@ -468,7 +476,7 @@ stateDiagram-v2
|
|
|
468
476
|
```
|
|
469
477
|
|
|
470
478
|
## Protocol / Wire Format
|
|
471
|
-
`WebSocketManager.initWebSocket` accepts `{body: SubscribeRequest, resource: string}`. Subscription uses the host Webex request API; AqmReqs operational HTTP uses the WebexRequest wrapper. AQM request configs carry `host`, `url`, optional `method`/`data`, `notifSuccess.bind`, optional `notifFail.bind`, optional cancel bind, and optional timeout. HTTP acknowledgement never substitutes for the matching WebSocket operation result.
|
|
479
|
+
`WebSocketManager.initWebSocket` accepts `{body: SubscribeRequest, resource: string}`. Subscription uses the host Webex request API; the returned `webSocketUrl` must be a `wss` URL on the `ALLOWED_ROUTING_NOTIFS_DOMAINS` allow-list or the socket is not opened. AqmReqs operational HTTP uses the WebexRequest wrapper. AQM request configs carry `host`, `url`, optional `method`/`data`, `notifSuccess.bind`, optional `notifFail.bind`, optional cancel bind, and optional timeout. HTTP acknowledgement never substitutes for the matching WebSocket operation result.
|
|
472
480
|
|
|
473
481
|
## Error Handling & Failure Modes
|
|
474
482
|
| Condition | Signal (error/code/result) | Caller recovery |
|
|
@@ -712,7 +720,8 @@ export const generateTaskErrorObject = (
|
|
|
712
720
|
## Pitfalls
|
|
713
721
|
- `initWebSocket` requires both `body` and `resource`; omitting `resource` registers against no durable subscription endpoint.
|
|
714
722
|
- AqmReqs installs notification binds before HTTP and never resolves from acknowledgement; duplicate binds, timeout, and cleanup ordering are correctness-critical.
|
|
715
|
-
-
|
|
723
|
+
- AQM routing-failure logs are always redacted; sensitive AQM configurations must also use the opt-in `redactSensitiveLogs` flag so dynamic URLs and responses are not included in timeout logs.
|
|
724
|
+
- Do not open the WebSocket directly from a subscribe/register response; the `webSocketUrl` must first pass `wss` scheme and allow-listed host validation.
|
|
716
725
|
- Keepalive closure (16 seconds), lost-connection detection (8 seconds), reconnect interval (5 seconds), and recovery timeout (50 seconds) are separate controls and must not be conflated.
|
|
717
726
|
|
|
718
727
|
## Module Do's / Don'ts
|
|
@@ -751,6 +760,7 @@ Unit tests mirror module paths under `test/unit/spec/services/core`. Preserve po
|
|
|
751
760
|
| `CORE-R-005` | `test/unit/spec/services/core/websocket/WebSocketManager.ts` | Keep timer-value assertions synchronized with constants. |
|
|
752
761
|
| `CORE-R-006` | `test/unit/spec/services/core/websocket/WebSocketManager.ts` | Feature-gate absence is verified from construction/source rather than a dedicated negative test. |
|
|
753
762
|
| `CORE-R-007` | `test/unit/spec/services/core/aqm-reqs.ts` | None. |
|
|
763
|
+
| `CORE-R-008` | `test/unit/spec/services/core/websocket/WebSocketManager.ts` | None. |
|
|
754
764
|
|
|
755
765
|
## Traceability
|
|
756
766
|
- Repo architecture: `../../../../ai-docs/ARCHITECTURE.md` · Registry: `../../../../ai-docs/SPEC_INDEX.md`
|
|
@@ -119,21 +119,12 @@ export default class AqmReqs {
|
|
|
119
119
|
if ('errId' in notifFail) {
|
|
120
120
|
const error = new Err.Details(notifFail.errId, msg as any);
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
LoggerProxy.log(`Routing request failed: ${JSON.stringify(msg)}`, {
|
|
129
|
-
module: AQM_REQS_FILE,
|
|
130
|
-
method: METHODS.CREATE_PROMISE,
|
|
131
|
-
});
|
|
132
|
-
LoggerProxy.log(`Routing request failed: ${error}`, {
|
|
133
|
-
module: AQM_REQS_FILE,
|
|
134
|
-
method: METHODS.CREATE_PROMISE,
|
|
135
|
-
});
|
|
136
|
-
}
|
|
122
|
+
// The raw routing-failure payload/error is never logged verbatim; only a
|
|
123
|
+
// redacted, non-sensitive diagnostic is emitted regardless of redactSensitiveLogs.
|
|
124
|
+
LoggerProxy.log('Routing request failed (sensitive details redacted)', {
|
|
125
|
+
module: AQM_REQS_FILE,
|
|
126
|
+
method: METHODS.CREATE_PROMISE,
|
|
127
|
+
});
|
|
137
128
|
reject(error);
|
|
138
129
|
} else {
|
|
139
130
|
reject(notifFail.err(msg as any));
|
|
@@ -80,6 +80,14 @@ export const PARTICIPANT_TYPES = {
|
|
|
80
80
|
/** Interaction state for consultation */
|
|
81
81
|
export const STATE_CONSULT = 'consult';
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Trusted domains for the RoutingNotifs WebSocket connection. A `webSocketUrl` returned by the
|
|
85
|
+
* subscribe/register API is only opened when its hostname matches one of these domains exactly
|
|
86
|
+
* or is a subdomain of one of them.
|
|
87
|
+
* @ignore
|
|
88
|
+
*/
|
|
89
|
+
export const ALLOWED_ROUTING_NOTIFS_DOMAINS = ['cisco.com', 'ciscoccservice.com'];
|
|
90
|
+
|
|
83
91
|
// Method names for core services
|
|
84
92
|
export const METHODS = {
|
|
85
93
|
// WebexRequest methods
|
|
@@ -5,8 +5,14 @@ import {ConnectionLostDetails} from './types';
|
|
|
5
5
|
import {CC_EVENTS, SubscribeResponse, WelcomeResponse} from '../../config/types';
|
|
6
6
|
import LoggerProxy from '../../../logger-proxy';
|
|
7
7
|
import workerScript from './keepalive.worker';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
KEEPALIVE_WORKER_INTERVAL,
|
|
10
|
+
CLOSE_SOCKET_TIMEOUT,
|
|
11
|
+
METHODS,
|
|
12
|
+
ALLOWED_ROUTING_NOTIFS_DOMAINS,
|
|
13
|
+
} from '../constants';
|
|
9
14
|
import {WEB_SOCKET_MANAGER_FILE} from '../../../constants';
|
|
15
|
+
import {isAllowedUrlHost} from '../Utils';
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
18
|
* WebSocketManager handles the WebSocket connection for Contact Center operations.
|
|
@@ -107,7 +113,25 @@ export class WebSocketManager extends EventEmitter {
|
|
|
107
113
|
body: connectionConfig,
|
|
108
114
|
headers: isIntEnv && orgId ? {'X-ORGANIZATION-ID': orgId} : undefined,
|
|
109
115
|
});
|
|
110
|
-
|
|
116
|
+
const {webSocketUrl} = subscribeResponse.body;
|
|
117
|
+
|
|
118
|
+
// Only a `wss` URL whose hostname is, or is a subdomain of, one of the allow-listed
|
|
119
|
+
// RoutingNotifs domains is considered trusted.
|
|
120
|
+
let isWssUrl = false;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
isWssUrl = !!webSocketUrl && new URL(webSocketUrl).protocol === 'wss:';
|
|
124
|
+
} catch (error) {
|
|
125
|
+
isWssUrl = false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!isWssUrl || !isAllowedUrlHost(webSocketUrl, ALLOWED_ROUTING_NOTIFS_DOMAINS)) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
'Rejected webSocketUrl from RoutingNotifs registration: scheme or host not allow-listed'
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.url = webSocketUrl;
|
|
111
135
|
} catch (e) {
|
|
112
136
|
LoggerProxy.error(
|
|
113
137
|
`Register API Failed, Request to RoutingNotifs websocket registration API failed ${e}`,
|
package/test/unit/spec/cc.ts
CHANGED
|
@@ -4868,6 +4868,27 @@ describe('webex.cc', () => {
|
|
|
4868
4868
|
});
|
|
4869
4869
|
});
|
|
4870
4870
|
|
|
4871
|
+
it('does not emit an unknown server-controlled event type', () => {
|
|
4872
|
+
const payload = {
|
|
4873
|
+
type: 'UnhandledMessage',
|
|
4874
|
+
data: {foo: 'bar', type: 'MaliciousInjectedType'},
|
|
4875
|
+
};
|
|
4876
|
+
|
|
4877
|
+
messageCallback(JSON.stringify(payload));
|
|
4878
|
+
|
|
4879
|
+
expect(emitSpy).not.toHaveBeenCalledWith('MaliciousInjectedType', expect.anything());
|
|
4880
|
+
});
|
|
4881
|
+
|
|
4882
|
+
it('emits a known agent event type verbatim from data.type', () => {
|
|
4883
|
+
const sample = {foo: 'bar', type: CC_EVENTS.AGENT_STATE_CHANGE_SUCCESS};
|
|
4884
|
+
|
|
4885
|
+
messageCallback(
|
|
4886
|
+
JSON.stringify({type: CC_EVENTS.AGENT_STATE_CHANGE_SUCCESS, data: sample})
|
|
4887
|
+
);
|
|
4888
|
+
|
|
4889
|
+
expect(emitSpy).toHaveBeenCalledWith(CC_EVENTS.AGENT_STATE_CHANGE_SUCCESS, sample);
|
|
4890
|
+
});
|
|
4891
|
+
|
|
4871
4892
|
it('should call webCallingService.setLoginOption with correct deviceType on AGENT_STATION_LOGIN_SUCCESS', () => {
|
|
4872
4893
|
const setLoginOptionSpy = jest.spyOn(webex.cc.webCallingService, 'setLoginOption');
|
|
4873
4894
|
const deviceType = LoginOption.EXTENSION;
|
|
@@ -219,6 +219,19 @@ describe('ApiAIAssistant', () => {
|
|
|
219
219
|
expect(result).toEqual(responseBody as any);
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
+
it('rejects an invalid agentId/interactionId and does not call webex.request', async () => {
|
|
223
|
+
apiAIAssistant.setAIFeatureFlags({realtimeTranscripts: {enable: true}} as any);
|
|
224
|
+
|
|
225
|
+
await expect(
|
|
226
|
+
apiAIAssistant.fetchHistoricTranscripts('', 'interaction-1')
|
|
227
|
+
).rejects.toBeDefined();
|
|
228
|
+
await expect(
|
|
229
|
+
apiAIAssistant.fetchHistoricTranscripts('test-agent-id', 'bad id!')
|
|
230
|
+
).rejects.toBeDefined();
|
|
231
|
+
|
|
232
|
+
expect(mockWebex.request).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
|
|
222
235
|
it('should request real-time assistance without extra context using sendEvent', async () => {
|
|
223
236
|
const sendEventSpy = jest.spyOn(apiAIAssistant, 'sendEvent').mockResolvedValue({ok: true});
|
|
224
237
|
apiAIAssistant.setAIFeatureFlags({suggestedResponses: {enable: true}} as any);
|
|
@@ -198,6 +198,44 @@ describe('WebCallingService', () => {
|
|
|
198
198
|
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
it('should fall back to default domain for a non-allow-listed catalog RTMS host', async () => {
|
|
202
|
+
webex.internal.services.get.mockReturnValue('wss://evil.attacker.com');
|
|
203
|
+
|
|
204
|
+
line = callingClient.getLines().line1 as ILine;
|
|
205
|
+
const deviceInfo = {
|
|
206
|
+
mobiusDeviceId: 'device123',
|
|
207
|
+
status: 'registered',
|
|
208
|
+
setError: jest.fn(),
|
|
209
|
+
getError: jest.fn(),
|
|
210
|
+
type: 'line',
|
|
211
|
+
id: 'line1',
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const registeredHandler = jest.fn();
|
|
215
|
+
const lineOnSpy = jest.spyOn(line, 'on').mockImplementation((event, handler) => {
|
|
216
|
+
if (event === LINE_EVENTS.REGISTERED) {
|
|
217
|
+
registeredHandler.mockImplementation(handler);
|
|
218
|
+
handler(deviceInfo);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
await expect(webRTCCalling.registerWebCallingLine()).resolves.toBeUndefined();
|
|
222
|
+
expect(createClient).toHaveBeenCalledWith(webex, {
|
|
223
|
+
logger: {
|
|
224
|
+
level: 'info',
|
|
225
|
+
},
|
|
226
|
+
serviceData: {
|
|
227
|
+
indicator: 'contactcenter',
|
|
228
|
+
domain: 'rtw.prod-us1.rtmsprod.net',
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
expect(lineOnSpy).toHaveBeenCalledWith(LINE_EVENTS.REGISTERED, expect.any(Function));
|
|
232
|
+
expect(line.register).toHaveBeenCalled();
|
|
233
|
+
expect(LoggerProxy.error).toHaveBeenCalledWith(
|
|
234
|
+
'Non-allow-listed RTMS host from u2c catalogue: evil.attacker.com so falling back to default domain',
|
|
235
|
+
{module: WEB_CALLING_SERVICE_FILE, method: 'getRTMSDomain'}
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
201
239
|
it('should reject if registration times out', async () => {
|
|
202
240
|
line = callingClient.getLines().line1 as ILine;
|
|
203
241
|
jest.spyOn(global, 'setTimeout').mockImplementation(((handler: TimerHandler) => {
|
|
@@ -1036,4 +1036,26 @@ describe('Utils', () => {
|
|
|
1036
1036
|
});
|
|
1037
1037
|
});
|
|
1038
1038
|
|
|
1039
|
+
describe('isAllowedUrlHost', () => {
|
|
1040
|
+
it('returns true when the hostname exactly matches an allowed domain', () => {
|
|
1041
|
+
expect(Utils.isAllowedUrlHost('wss://cisco.com', ['cisco.com'])).toBe(true);
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
it('returns true when the hostname is a subdomain of an allowed domain', () => {
|
|
1045
|
+
expect(Utils.isAllowedUrlHost('wss://notifs.wxcc-us1.cisco.com', ['cisco.com'])).toBe(true);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it('returns false when the hostname is not in the allowed domains list', () => {
|
|
1049
|
+
expect(Utils.isAllowedUrlHost('wss://evil.attacker.com', ['cisco.com'])).toBe(false);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
it('returns false for an unparsable url', () => {
|
|
1053
|
+
expect(Utils.isAllowedUrlHost('not-a-url', ['cisco.com'])).toBe(false);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it('returns false for a null/undefined url', () => {
|
|
1057
|
+
expect(Utils.isAllowedUrlHost(null as any, ['cisco.com'])).toBe(false);
|
|
1058
|
+
expect(Utils.isAllowedUrlHost(undefined as any, ['cisco.com'])).toBe(false);
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1039
1061
|
});
|
|
@@ -610,6 +610,35 @@ describe('AqmReqs', () => {
|
|
|
610
610
|
expect(aqm['pendingRequests']).toEqual({});
|
|
611
611
|
});
|
|
612
612
|
|
|
613
|
+
it('never logs the raw routing-failure payload even when redactSensitiveLogs is not set', async () => {
|
|
614
|
+
const participantId = '+1/plain-failure-secret';
|
|
615
|
+
webexRequestInstance.request.mockResolvedValueOnce(mockWebexRequestResolvedValue);
|
|
616
|
+
const conf = {
|
|
617
|
+
...participantDropConfig(participantId, 'disabled'),
|
|
618
|
+
redactSensitiveLogs: false,
|
|
619
|
+
};
|
|
620
|
+
const promise = aqm['createPromise'](conf);
|
|
621
|
+
|
|
622
|
+
webSocketManagerInstance.emit(
|
|
623
|
+
'message',
|
|
624
|
+
JSON.stringify({
|
|
625
|
+
type: 'RoutingMessage',
|
|
626
|
+
data: {type: 'ParticipantDropConferenceFailed', participantId},
|
|
627
|
+
})
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
await expect(promise).rejects.toBeDefined();
|
|
631
|
+
expect(LoggerProxy.log).toHaveBeenCalledWith(
|
|
632
|
+
'Routing request failed (sensitive details redacted)',
|
|
633
|
+
expect.objectContaining({module: AQM_REQS_FILE})
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const loggedCalls = JSON.stringify((LoggerProxy.log as jest.Mock).mock.calls);
|
|
637
|
+
expect(loggedCalls).not.toContain(participantId);
|
|
638
|
+
expect(loggedCalls).not.toContain('ParticipantDropConferenceFailed');
|
|
639
|
+
expect(aqm['pendingRequests']).toEqual({});
|
|
640
|
+
});
|
|
641
|
+
|
|
613
642
|
it('resolves a correlated participant-left event and clears its binds', async () => {
|
|
614
643
|
jest.useFakeTimers();
|
|
615
644
|
(global.window as any).setTimeout = global.setTimeout;
|