@xema/omni-protocol 0.1.98 → 0.1.99
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/dist/index.d.ts +128 -106
- package/dist/index.js +5 -15
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +41 -27
- package/dist/validation.d.ts +20 -6
- package/dist/validation.js +175 -202
- package/guide.md +235 -284
- package/package.json +1 -1
package/dist/validation.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Each list is pinned to its type both ways -- a member the type lacks, or a member the list
|
|
10
10
|
// lacks, fails to compile -- so what the validators accept cannot drift from what the
|
|
11
11
|
// declarations say.
|
|
12
|
-
import { RECORDING_ACTIONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS,
|
|
12
|
+
import { RECORDING_ACTIONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HISTORY_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
|
|
13
13
|
export class ProtocolConformanceError extends Error {
|
|
14
14
|
violations;
|
|
15
15
|
constructor(violations, summary = "Adapter violates the Omni protocol") {
|
|
@@ -78,7 +78,7 @@ const DIALLING_CAPABILITIES = ["connectBack", "coldTransfer", "warmTransfer", "c
|
|
|
78
78
|
const SNAPSHOT_REASONS = membersOf({
|
|
79
79
|
reconnected: true, "provider-requested": true,
|
|
80
80
|
});
|
|
81
|
-
const SESSION_CAPABILITIES = membersOf({ breaks: true,
|
|
81
|
+
const SESSION_CAPABILITIES = membersOf({ breaks: true, lead: true, preferences: true });
|
|
82
82
|
const MEMBER_BREAKS = membersOf({
|
|
83
83
|
"awaiting-approval": true, granted: true, "starting-after-task": true,
|
|
84
84
|
});
|
|
@@ -87,7 +87,6 @@ const OFFERABLE_PHASES = membersOf({
|
|
|
87
87
|
});
|
|
88
88
|
const PREVIEW_DEADLINES = membersOf({ calls: true, "host-calls": true, waits: true });
|
|
89
89
|
const PHONES = membersOf({ softphone: true, deskPhone: true });
|
|
90
|
-
const TEAM_CAPABILITIES = membersOf({ breakControl: true, leadAssistControl: true, policyControl: true, listeningControl: true });
|
|
91
90
|
const LISTENING_MODES = membersOf({ listen: true, coach: true, "join-call": true });
|
|
92
91
|
const COMPLETED_BY = membersOf({ agent: true, provider: true });
|
|
93
92
|
const CANCELLED_BY = membersOf({ agent: true, provider: true, party: true });
|
|
@@ -99,7 +98,7 @@ const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
|
|
|
99
98
|
const TASK_CAPABILITIES = {
|
|
100
99
|
voice: membersOf({
|
|
101
100
|
browsers: true, outcomes: true, custom: true, decline: true, hold: true,
|
|
102
|
-
endCall: true, connectBack: true, coldTransfer: true, warmTransfer: true, leadAssist: true, conference: true, recording: true,
|
|
101
|
+
endCall: true, terminateCall: true, connectBack: true, coldTransfer: true, warmTransfer: true, leadAssist: true, conference: true, recording: true,
|
|
103
102
|
}),
|
|
104
103
|
chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true }),
|
|
105
104
|
email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true }),
|
|
@@ -1073,42 +1072,24 @@ function validateLeadAssist(value, channel, path, into) {
|
|
|
1073
1072
|
into.filled(value.note, "task.leadAssist.note", `${path}.note`, "a note must not be empty when present");
|
|
1074
1073
|
into.timestamp(value.since, "task.leadAssist.since", `${path}.since`);
|
|
1075
1074
|
}
|
|
1076
|
-
/**
|
|
1077
|
-
function
|
|
1075
|
+
/** A call a lead took over: which member it came from, and when. Voice only. */
|
|
1076
|
+
function validateTakenOver(value, channel, path, into) {
|
|
1078
1077
|
if (value === undefined)
|
|
1079
1078
|
return;
|
|
1080
|
-
if (!into.require(channel === "voice", "task.
|
|
1079
|
+
if (!into.require(channel === "voice", "task.takenOver.channel", path, `a ${channel} task has no call to take over`))
|
|
1081
1080
|
return;
|
|
1082
1081
|
if (!isPlainObject(value)) {
|
|
1083
|
-
into.add("task.
|
|
1082
|
+
into.add("task.takenOver.shape", path, "takenOver must be an object when present");
|
|
1084
1083
|
return;
|
|
1085
1084
|
}
|
|
1086
|
-
into.require(isUserId(value.memberId), "task.
|
|
1087
|
-
into.
|
|
1088
|
-
if (value.note !== undefined)
|
|
1089
|
-
into.filled(value.note, "task.assisting.note", `${path}.note`, "a note must not be empty when present");
|
|
1090
|
-
into.timestamp(value.since, "task.assisting.since", `${path}.since`);
|
|
1085
|
+
into.require(isUserId(value.memberId), "task.takenOver.memberId", `${path}.memberId`, "a taken-over call names the member it was taken from");
|
|
1086
|
+
into.timestamp(value.since, "task.takenOver.since", `${path}.since`);
|
|
1091
1087
|
}
|
|
1092
|
-
/**
|
|
1093
|
-
function validateListening(value, channel, path, into) {
|
|
1094
|
-
if (value === undefined)
|
|
1095
|
-
return;
|
|
1096
|
-
if (!into.require(channel === "voice", "task.listening.channel", path, `a ${channel} task has no call to listen to`))
|
|
1097
|
-
return;
|
|
1098
|
-
if (!isPlainObject(value)) {
|
|
1099
|
-
into.add("task.listening.shape", path, "listening must be an object when present");
|
|
1100
|
-
return;
|
|
1101
|
-
}
|
|
1102
|
-
into.require(isUserId(value.memberId), "task.listening.memberId", `${path}.memberId`, "a call being listened to names the member on it");
|
|
1103
|
-
into.require(isAssignmentId(value.assignmentId), "task.listening.assignmentId", `${path}.assignmentId`, "a call being listened to names the member's assignment");
|
|
1104
|
-
into.oneOf(value.mode, LISTENING_MODES, "task.listening.mode", `${path}.mode`);
|
|
1105
|
-
into.timestamp(value.since, "task.listening.since", `${path}.since`);
|
|
1106
|
-
}
|
|
1107
|
-
/** Whether a task is the agent's own doing: a dial the host placed on its onCall, a lead's join, a lead's listen. */
|
|
1088
|
+
/** Whether a task is the agent's own doing: a dial the host placed on its onCall, or a call a lead took over. */
|
|
1108
1089
|
function originatedByTheAgent(task) {
|
|
1109
1090
|
if (Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && typeof entry.dialId === "string"))
|
|
1110
1091
|
return true;
|
|
1111
|
-
return task.
|
|
1092
|
+
return task.takenOver !== undefined;
|
|
1112
1093
|
}
|
|
1113
1094
|
export function validateTask(task, context, path = "task") {
|
|
1114
1095
|
const into = new Collector();
|
|
@@ -1120,7 +1101,11 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1120
1101
|
into.add("task.shape", path, "a task must be an object");
|
|
1121
1102
|
return;
|
|
1122
1103
|
}
|
|
1123
|
-
|
|
1104
|
+
// A task carries no lead state: the lead's view is the team member list, and a lead's own call
|
|
1105
|
+
// is an ordinary assignment marked takenOver.
|
|
1106
|
+
for (const retired of ["assisting", "listening", "monitoring"]) {
|
|
1107
|
+
into.require(!Object.hasOwn(task, retired), "task.leadState.retired", `${path}.${retired}`, "a task carries no lead state; a call a lead took over says takenOver, and the lead's view is the team member list");
|
|
1108
|
+
}
|
|
1124
1109
|
for (const former of ["handlingHistory", "interactionHistory"]) {
|
|
1125
1110
|
into.require(!Object.hasOwn(task, former), "task.history.renamed", `${path}.${former}`, "use history; the former field is not accepted");
|
|
1126
1111
|
}
|
|
@@ -1166,24 +1151,26 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1166
1151
|
into.add("task.acceptance.unexpected", `${path}.acceptance`, "acceptance is an offer's word; a task past pending has been accepted");
|
|
1167
1152
|
}
|
|
1168
1153
|
else if (originated) {
|
|
1169
|
-
into.require(task.acceptance === "automatic", "task.acceptance.originated", `${path}.acceptance`, "work the agent originated -- a dial, a connect-back, a
|
|
1154
|
+
into.require(task.acceptance === "automatic", "task.acceptance.originated", `${path}.acceptance`, "work the agent originated -- a dial, a connect-back, a call taken over -- was accepted by the command that created it, and says automatic whatever the provisioning");
|
|
1170
1155
|
}
|
|
1171
1156
|
else if (context.autoAcceptTasks === false) {
|
|
1172
1157
|
into.add("task.acceptance.unexpected", `${path}.acceptance`, "autoAcceptTasks is off, so every task the queue routes requires agent acceptance and a pending task carries no acceptance");
|
|
1173
1158
|
}
|
|
1174
1159
|
}
|
|
1175
1160
|
else if (originated) {
|
|
1176
|
-
into.add("task.acceptance.originated", `${path}.acceptance`, "work the agent originated -- a dial, a connect-back, a
|
|
1161
|
+
into.add("task.acceptance.originated", `${path}.acceptance`, "work the agent originated -- a dial, a connect-back, a call taken over -- says acceptance: automatic whatever the provisioning: the command that created it accepted it");
|
|
1177
1162
|
}
|
|
1178
1163
|
else if (task.phase === "pending" && context.autoAcceptTasks === true) {
|
|
1179
1164
|
into.add("task.acceptance.required", `${path}.acceptance`, "autoAcceptTasks is on, so a pending task states how it is accepted");
|
|
1180
1165
|
}
|
|
1181
|
-
|
|
1166
|
+
if (context.member !== true || task.completionMode !== undefined)
|
|
1167
|
+
into.oneOf(task.completionMode, COMPLETION_MODES, "task.completionMode", `${path}.completionMode`);
|
|
1182
1168
|
// The allowance is coupled to the mode: a provider that will complete the task itself is going
|
|
1183
1169
|
// to act on the allowance, so it must state one; a provider waiting for `complete` may omit it
|
|
1184
1170
|
// to say it imposes no deadline. Present, it is a duration either way.
|
|
1185
1171
|
if (task.wrapAllowance === undefined) {
|
|
1186
|
-
|
|
1172
|
+
if (context.member !== true)
|
|
1173
|
+
into.require(task.completionMode !== "provider-automatic", "task.wrapAllowance.required", `${path}.wrapAllowance`, "provider-automatic completion needs an allowance to act on");
|
|
1187
1174
|
}
|
|
1188
1175
|
else {
|
|
1189
1176
|
into.require(isDurationSeconds(task.wrapAllowance), "task.wrapAllowance", `${path}.wrapAllowance`, "wrapAllowance must be a whole number of seconds, zero or more, or omitted under agent-command");
|
|
@@ -1210,7 +1197,8 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1210
1197
|
into.violations.push(...validateRecordingState(task.recording.provider, `${path}.recording.provider`));
|
|
1211
1198
|
}
|
|
1212
1199
|
}
|
|
1213
|
-
|
|
1200
|
+
if (context.member !== true || task.browsers !== undefined)
|
|
1201
|
+
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
1214
1202
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
|
|
1215
1203
|
validateHistory(task.history, `${path}.history`, into, { phase: task.phase, media: task.media });
|
|
1216
1204
|
validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
|
|
@@ -1225,16 +1213,14 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1225
1213
|
}
|
|
1226
1214
|
validateTaskMedia(task, task.media, context.channel, task.phase, `${path}.media`, into);
|
|
1227
1215
|
validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
|
|
1228
|
-
|
|
1229
|
-
validateListening(task.listening, context.channel, `${path}.listening`, into);
|
|
1230
|
-
// A lead's task is one thing: a call they joined on request, or a call they listen to unasked.
|
|
1231
|
-
if (task.assisting !== undefined && task.listening !== undefined) {
|
|
1232
|
-
into.add("task.listening.assisting", `${path}.listening`, "a task is a joined call or a listened one, never both");
|
|
1233
|
-
}
|
|
1216
|
+
validateTakenOver(task.takenOver, context.channel, `${path}.takenOver`, into);
|
|
1234
1217
|
// Where the terms came from is stated with them: a host cannot tell "the platform permits
|
|
1235
1218
|
// nothing" from "the terms could not be read" from the set alone, and the provider knows which.
|
|
1236
|
-
|
|
1219
|
+
if (context.member !== true || task.capabilitySource !== undefined)
|
|
1220
|
+
into.oneOf(task.capabilitySource, CAPABILITY_SOURCES, "task.capabilitySource", `${path}.capabilitySource`);
|
|
1237
1221
|
const capabilities = task.capabilities;
|
|
1222
|
+
if (context.member === true && capabilities === undefined && task.browsers === undefined)
|
|
1223
|
+
return;
|
|
1238
1224
|
if (!isPlainObject(capabilities)) {
|
|
1239
1225
|
into.add("task.capabilities.shape", `${path}.capabilities`, "a task needs a capabilities object");
|
|
1240
1226
|
return;
|
|
@@ -1319,8 +1305,7 @@ export function validateBreakStatus(state, tasks, path = "snapshot") {
|
|
|
1319
1305
|
});
|
|
1320
1306
|
// A break in effect begins when the work ends, so it holds no task. A snapshot reporting both
|
|
1321
1307
|
// describes a state the agent cannot be in, whichever half is stale. The one exception is a
|
|
1322
|
-
//
|
|
1323
|
-
// administrative, training -- and on no other.
|
|
1308
|
+
// call a lead took over: the provider assigns it whatever break the lead is on.
|
|
1324
1309
|
// The converse: a committed break with nothing outstanding has begun. starting-after-task beside
|
|
1325
1310
|
// no task is a break waiting on work that does not exist, and an empty list reading as "still
|
|
1326
1311
|
// finishing" is the plausible nought.
|
|
@@ -1331,79 +1316,103 @@ export function validateBreakStatus(state, tasks, path = "snapshot") {
|
|
|
1331
1316
|
}
|
|
1332
1317
|
if (isPlainObject(state) && state.status === "on-break"
|
|
1333
1318
|
&& Array.isArray(tasks) && tasks.length > 0) {
|
|
1334
|
-
const
|
|
1335
|
-
if (!
|
|
1336
|
-
into.add("break.on-break.tasks", `${path}.tasks`, "a break in effect holds no task: it begins when the work ends, and until then the state is starting-after-task");
|
|
1337
|
-
}
|
|
1338
|
-
else {
|
|
1339
|
-
const reasons = Array.isArray(state.reasons) ? state.reasons : [];
|
|
1340
|
-
const active = reasons.find((reason) => isPlainObject(reason) && reason.id === state.activeReasonId);
|
|
1341
|
-
const kind = isPlainObject(active) ? active.kind : undefined;
|
|
1342
|
-
into.require(typeof kind === "string" && LISTENING_BREAK_KINDS.includes(kind), "snapshot.listening.break", `${path}.tasks`, `a lead listens during a ${LISTENING_BREAK_KINDS.join(", ")} break and no other; the active reason is ${kind === undefined ? "unstated" : describeValue(kind)}`);
|
|
1319
|
+
const takenOver = tasks.every((task) => isPlainObject(task) && task.takenOver !== undefined);
|
|
1320
|
+
if (!takenOver) {
|
|
1321
|
+
into.add("break.on-break.tasks", `${path}.tasks`, "a break in effect holds no task: it begins when the work ends, and until then the state is starting-after-task; the one exception is a call a lead took over");
|
|
1343
1322
|
}
|
|
1344
1323
|
}
|
|
1345
1324
|
return into.violations;
|
|
1346
1325
|
}
|
|
1347
1326
|
/**
|
|
1348
|
-
* Checks lead
|
|
1349
|
-
* for force-break
|
|
1350
|
-
*
|
|
1327
|
+
* Checks a lead command before dispatch, against the current authentication, transport and team
|
|
1328
|
+
* member list (`team`) and, for force-break and end-forced-break, the target's full `memberBreak`.
|
|
1329
|
+
* The authority is the login's `lead` flag; the provider rechecks the target and the state
|
|
1330
|
+
* atomically, since a team member list is not authority to act after it has changed.
|
|
1351
1331
|
*/
|
|
1352
|
-
export function
|
|
1332
|
+
export function validateTeamCommand(request, context, path = "teamCommand") {
|
|
1353
1333
|
const into = new Collector();
|
|
1354
1334
|
if (!isPlainObject(context) || !isPlainObject(request) || !isPlainObject(request.command)) {
|
|
1355
|
-
into.add("team.
|
|
1335
|
+
into.add("team.command.shape", path, "a command and current authentication/transport/team context are required");
|
|
1356
1336
|
return into.violations;
|
|
1357
1337
|
}
|
|
1358
1338
|
const auth = isPlainObject(context.authentication) ? context.authentication : {};
|
|
1359
1339
|
into.violations.push(...validateAuthenticationState(auth, `${path}.authentication`));
|
|
1360
1340
|
const caps = isPlainObject(auth.capabilities) ? auth.capabilities : {};
|
|
1361
|
-
into.require((auth.status === "authenticated" || auth.status === "refreshing") && context.transport === "active", "team.
|
|
1362
|
-
into.require(
|
|
1341
|
+
into.require((auth.status === "authenticated" || auth.status === "refreshing") && context.transport === "active", "team.command.connection", path, "lead commands require a live login and active transport");
|
|
1342
|
+
into.require(caps.lead === true, "team.command.capability", path, "the login must declare lead");
|
|
1363
1343
|
const command = request.command;
|
|
1344
|
+
const member = ["type", "memberId", "assignmentId"];
|
|
1364
1345
|
const allowed = {
|
|
1346
|
+
"lead-features": ["type", "enabled"],
|
|
1365
1347
|
"decide-break-request": ["type", "memberId", "decision", "reason"], "set-break-policy": ["type", "policy"],
|
|
1366
1348
|
"force-break": ["type", "memberId", "reasonId", "reason", "expectedDurationMs"], "end-forced-break": ["type", "memberId"],
|
|
1349
|
+
join: member, decline: [...member, "reason"], listen: member, coach: member, "join-call": member, leave: member, "take-over-call": member,
|
|
1350
|
+
"set-policy": ["type", "capability", "setting"],
|
|
1367
1351
|
};
|
|
1368
1352
|
const fields = typeof command.type === "string" && Object.hasOwn(allowed, command.type) ? allowed[command.type] : undefined;
|
|
1369
1353
|
if (!fields) {
|
|
1370
|
-
into.add("team.
|
|
1354
|
+
into.add("team.command.type", path, `unknown lead command: ${describeValue(command.type)}`);
|
|
1371
1355
|
return into.violations;
|
|
1372
1356
|
}
|
|
1373
1357
|
for (const key of Object.keys(request))
|
|
1374
|
-
into.require(key === "command", "team.
|
|
1358
|
+
into.require(key === "command", "team.request.field", `${path}.${key}`, "only command is supported");
|
|
1375
1359
|
for (const key of Object.keys(command))
|
|
1376
|
-
into.require(fields.includes(key), "team.
|
|
1360
|
+
into.require(fields.includes(key), "team.command.field", `${path}.${key}`, "field is not supported for this command");
|
|
1377
1361
|
if (command.reason !== undefined)
|
|
1378
|
-
into.filled(command.reason, "team.
|
|
1362
|
+
into.filled(command.reason, "team.command.reason", path, "reason must not be empty");
|
|
1363
|
+
if (command.type === "lead-features") {
|
|
1364
|
+
into.require(typeof command.enabled === "boolean", "team.command.enabled", `${path}.enabled`, "lead-features says on or off, as a boolean");
|
|
1365
|
+
return into.violations;
|
|
1366
|
+
}
|
|
1379
1367
|
if (command.type === "set-break-policy") {
|
|
1380
|
-
into.require(["approval-required", "automatically-approved", "requests-suspended"].includes(command.policy), "team.
|
|
1368
|
+
into.require(["approval-required", "automatically-approved", "requests-suspended"].includes(command.policy), "team.command.policy", path, "unknown break policy");
|
|
1381
1369
|
return into.violations;
|
|
1382
1370
|
}
|
|
1383
|
-
|
|
1371
|
+
if (command.type === "set-policy") {
|
|
1372
|
+
into.require(typeof command.capability === "string" && (POLICY_KEYS.has(command.capability) || /^skill:.+$/.test(command.capability)), "team.command.capability.key", `${path}.capability`, "a policy names a task control, dial, or a skill");
|
|
1373
|
+
if (into.oneOf(command.setting, POLICY_SETTINGS, "team.command.setting", `${path}.setting`)) {
|
|
1374
|
+
into.require(command.setting !== "person" || (typeof command.capability === "string" && PERSON_SETTABLE.test(command.capability)), "team.command.setting.person", `${path}.setting`, "only hold and skills may be left to the person");
|
|
1375
|
+
}
|
|
1376
|
+
return into.violations;
|
|
1377
|
+
}
|
|
1378
|
+
into.filled(command.memberId, "team.command.member", path, "name the target member");
|
|
1384
1379
|
const team = isPlainObject(context.team) ? context.team : {};
|
|
1385
1380
|
const members = Array.isArray(team.members) ? team.members : [];
|
|
1386
|
-
const
|
|
1381
|
+
const target = members.find((m) => isPlainObject(m) && m.id === command.memberId);
|
|
1387
1382
|
const self = isPlainObject(auth.identity) ? auth.identity.id : undefined;
|
|
1388
|
-
into.require(isPlainObject(
|
|
1383
|
+
into.require(isPlainObject(target) && command.memberId !== self, "team.command.member", path, "the target must be another member of the current team member list");
|
|
1384
|
+
if (command.assignmentId !== undefined) {
|
|
1385
|
+
into.require(isAssignmentId(command.assignmentId), "team.command.assignmentId", `${path}.assignmentId`, "an assignment id must be non-empty when present");
|
|
1386
|
+
const tasks = isPlainObject(target) && Array.isArray(target.tasks) ? target.tasks : undefined;
|
|
1387
|
+
if (tasks !== undefined)
|
|
1388
|
+
into.require(tasks.some((task) => isPlainObject(task) && task.assignmentId === command.assignmentId), "team.command.assignmentId.unknown", `${path}.assignmentId`, "the assignment named is not one the team member list shows the member holding");
|
|
1389
|
+
}
|
|
1389
1390
|
if (command.type === "decide-break-request") {
|
|
1390
|
-
into.require(command.decision === "granted" || command.decision === "denied", "team.
|
|
1391
|
-
into.require(isPlainObject(
|
|
1391
|
+
into.require(command.decision === "granted" || command.decision === "denied", "team.command.decision", path, "decide granted or denied");
|
|
1392
|
+
into.require(isPlainObject(target) && target.break === "awaiting-approval", "team.command.awaiting", path, "decide only a currently awaiting-approval request");
|
|
1392
1393
|
}
|
|
1393
|
-
else {
|
|
1394
|
+
else if (command.type === "force-break" || command.type === "end-forced-break") {
|
|
1394
1395
|
validateBreakState(context.memberBreak, `${path}.memberBreak`, into);
|
|
1395
1396
|
const state = isPlainObject(context.memberBreak) ? context.memberBreak : {};
|
|
1396
1397
|
if (command.type === "end-forced-break")
|
|
1397
|
-
into.require(state.forced !== undefined && (state.status === "on-break" || state.status === "starting-after-task"), "team.
|
|
1398
|
+
into.require(state.forced !== undefined && (state.status === "on-break" || state.status === "starting-after-task"), "team.command.endForcedBreak", path, "lift a current forced-break restriction without resuming the agent");
|
|
1398
1399
|
if (command.type === "force-break") {
|
|
1399
1400
|
if (command.expectedDurationMs !== undefined)
|
|
1400
|
-
into.require(typeof command.expectedDurationMs === "number" && Number.isFinite(command.expectedDurationMs) && command.expectedDurationMs > 0, "team.
|
|
1401
|
+
into.require(typeof command.expectedDurationMs === "number" && Number.isFinite(command.expectedDurationMs) && command.expectedDurationMs > 0, "team.command.expectedDurationMs", `${path}.expectedDurationMs`, "expectedDurationMs must be positive finite milliseconds; it never resumes the agent");
|
|
1401
1402
|
if (command.reasonId !== undefined)
|
|
1402
|
-
into.filled(command.reasonId, "team.
|
|
1403
|
+
into.filled(command.reasonId, "team.command.reasonId", path, "reasonId must not be empty");
|
|
1403
1404
|
const reasons = Array.isArray(state.reasons) ? state.reasons : [];
|
|
1404
|
-
into.require(state.reasons === undefined ? command.reasonId === undefined : reasons.some((r) => isPlainObject(r) && r.id === command.reasonId), "team.
|
|
1405
|
+
into.require(state.reasons === undefined ? command.reasonId === undefined : reasons.some((r) => isPlainObject(r) && r.id === command.reasonId), "team.command.reasonId", path, "force-break uses the target's currently published reason codes");
|
|
1405
1406
|
}
|
|
1406
1407
|
}
|
|
1408
|
+
else if (command.type === "join" || command.type === "decline") {
|
|
1409
|
+
const requests = Array.isArray(team.requests) ? team.requests : [];
|
|
1410
|
+
into.require(requests.some((r) => isPlainObject(r) && r.memberId === command.memberId
|
|
1411
|
+
&& (command.assignmentId === undefined || r.assignmentId === command.assignmentId)), "team.command.request", path, `${command.type} answers a request the team member list carries, and this member has none`);
|
|
1412
|
+
}
|
|
1413
|
+
else if (command.type === "coach" || command.type === "join-call" || command.type === "leave") {
|
|
1414
|
+
into.require(isPlainObject(target) && target.listening !== undefined, "team.command.listening", path, `${command.type} needs the lead on this member's call: the team member list shows listening on them`);
|
|
1415
|
+
}
|
|
1407
1416
|
return into.violations;
|
|
1408
1417
|
}
|
|
1409
1418
|
/**
|
|
@@ -1576,61 +1585,39 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
|
|
|
1576
1585
|
into.add("team.shape", path, "a team member list must be an object");
|
|
1577
1586
|
return;
|
|
1578
1587
|
}
|
|
1579
|
-
// The login is the permission: a team member list reaches a login that declares `capabilities.
|
|
1580
|
-
// nobody else. Only a caller holding the login can check it.
|
|
1581
|
-
if (context.capabilities !== undefined && context.capabilities.
|
|
1582
|
-
into.add("team.unentitled", path, "a team member list published to a login that does not declare capabilities.
|
|
1583
|
-
}
|
|
1584
|
-
// The team's policies travel with the team member list exactly when the login may set them.
|
|
1585
|
-
if (context.capabilities !== undefined) {
|
|
1586
|
-
const may = context.capabilities.team?.policyControl === true;
|
|
1587
|
-
if (may && teamMembers.policies === undefined) {
|
|
1588
|
-
into.add("team.policies.required", `${path}.policies`, "the login declares team.policyControl, so the team member list carries the team's policies");
|
|
1589
|
-
}
|
|
1590
|
-
if (!may && teamMembers.policies !== undefined) {
|
|
1591
|
-
into.add("team.policies.capability", `${path}.policies`, "policies require team.policyControl on the login: a lead who may not set them has nothing to see");
|
|
1592
|
-
}
|
|
1588
|
+
// The login is the permission: a team member list reaches a login that declares `capabilities.lead`
|
|
1589
|
+
// and nobody else. Only a caller holding the login can check it.
|
|
1590
|
+
if (context.capabilities !== undefined && context.capabilities.lead !== true) {
|
|
1591
|
+
into.add("team.unentitled", path, "a team member list published to a login that does not declare capabilities.lead: the login is the permission");
|
|
1593
1592
|
}
|
|
1593
|
+
// Policies are present where the provider offers policy control, absent where it does not.
|
|
1594
1594
|
if (teamMembers.policies !== undefined)
|
|
1595
1595
|
validateTeamPoliciesInto(teamMembers.policies, `${path}.policies`, into, context.levels);
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
if (context.capabilities?.team?.leadAssistControl === true) {
|
|
1600
|
-
into.add("team.requests.required", `${path}.requests`, "the login declares team.leadAssistControl, so the team member list carries requests: [] when nobody is asking");
|
|
1601
|
-
}
|
|
1596
|
+
// Requests are what a lead acts on: always carried, `[]` when nobody is asking.
|
|
1597
|
+
if (!Array.isArray(teamMembers.requests)) {
|
|
1598
|
+
into.add("team.requests.shape", `${path}.requests`, "a team member list carries its requests: [] when nobody is asking");
|
|
1602
1599
|
}
|
|
1603
1600
|
else {
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
const
|
|
1616
|
-
if (
|
|
1617
|
-
into.add("team.request.
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
}
|
|
1625
|
-
if (into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking")) {
|
|
1626
|
-
into.require(request.memberId !== context.self, "team.request.self", `${at}.memberId`, "the team member list carries the reader's own ask: an agent's request for a lead goes to whoever leads them");
|
|
1627
|
-
}
|
|
1628
|
-
into.require(isAssignmentId(request.assignmentId), "team.request.assignmentId", `${at}.assignmentId`, "a request names the member's assignment the lead would join");
|
|
1629
|
-
if (request.note !== undefined)
|
|
1630
|
-
into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
|
|
1631
|
-
into.timestamp(request.since, "team.request.since", `${at}.since`);
|
|
1632
|
-
});
|
|
1633
|
-
}
|
|
1601
|
+
const seenRequests = new Set();
|
|
1602
|
+
teamMembers.requests.forEach((request, index) => {
|
|
1603
|
+
const at = `${path}.requests[${index}]`;
|
|
1604
|
+
if (!isPlainObject(request)) {
|
|
1605
|
+
into.add("team.request.shape", at, "each request must be an object");
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
if (into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking")) {
|
|
1609
|
+
into.require(request.memberId !== context.self, "team.request.self", `${at}.memberId`, "the team member list carries the reader's own ask: an agent's request for a lead goes to whoever leads them");
|
|
1610
|
+
}
|
|
1611
|
+
if (into.require(isAssignmentId(request.assignmentId), "team.request.assignmentId", `${at}.assignmentId`, "a request names the member's assignment the lead would join")) {
|
|
1612
|
+
const key = `${String(request.memberId)}@${String(request.assignmentId)}`;
|
|
1613
|
+
if (seenRequests.has(key))
|
|
1614
|
+
into.add("team.request.unique", at, `duplicate request: ${key}`);
|
|
1615
|
+
seenRequests.add(key);
|
|
1616
|
+
}
|
|
1617
|
+
if (request.note !== undefined)
|
|
1618
|
+
into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
|
|
1619
|
+
into.timestamp(request.since, "team.request.since", `${at}.since`);
|
|
1620
|
+
});
|
|
1634
1621
|
}
|
|
1635
1622
|
if (!Array.isArray(teamMembers.members)) {
|
|
1636
1623
|
into.add("team.members.shape", `${path}.members`, "a team member list must carry a members array");
|
|
@@ -1659,7 +1646,40 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
|
|
|
1659
1646
|
into.require(member.availability !== "signed-out" && member.availability !== "on-break", "team.member.break.availability", `${at}.break`, "a member on a break or signed out has no request outstanding");
|
|
1660
1647
|
}
|
|
1661
1648
|
}
|
|
1649
|
+
// The member's open tasks, as the lead sees them: trimmed by the provider, held to the task rules otherwise.
|
|
1650
|
+
if (member.tasks !== undefined) {
|
|
1651
|
+
if (!Array.isArray(member.tasks)) {
|
|
1652
|
+
into.add("team.member.tasks.shape", `${at}.tasks`, "a member's tasks are an array: [] when they hold none, omitted only where the provider cannot see them");
|
|
1653
|
+
}
|
|
1654
|
+
else {
|
|
1655
|
+
const held = new Set();
|
|
1656
|
+
member.tasks.forEach((task, index) => {
|
|
1657
|
+
const channel = isPlainObject(task) && typeof task.channel === "string" && isChannel(task.channel) ? task.channel : context.channel ?? "voice";
|
|
1658
|
+
validateTaskInto(task, { channel, levels: context.levels, member: true }, `${at}.tasks[${index}]`, into);
|
|
1659
|
+
if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
|
|
1660
|
+
if (held.has(task.assignmentId))
|
|
1661
|
+
into.add("team.member.tasks.unique", `${at}.tasks[${index}].assignmentId`, `duplicate assignment on one member: ${task.assignmentId}`);
|
|
1662
|
+
held.add(task.assignmentId);
|
|
1663
|
+
}
|
|
1664
|
+
});
|
|
1665
|
+
into.require(member.availability !== "signed-out" || member.tasks.length === 0, "team.member.tasks.availability", `${at}.tasks`, "a member signed out holds no task");
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
// The lead on this member's call, in the mode they are heard.
|
|
1669
|
+
if (member.listening !== undefined) {
|
|
1670
|
+
if (!isPlainObject(member.listening)) {
|
|
1671
|
+
into.add("team.member.listening.shape", `${at}.listening`, "listening must be an object when present");
|
|
1672
|
+
}
|
|
1673
|
+
else {
|
|
1674
|
+
into.oneOf(member.listening.mode, LISTENING_MODES, "team.member.listening.mode", `${at}.listening.mode`);
|
|
1675
|
+
into.timestamp(member.listening.since, "team.member.listening.since", `${at}.listening.since`);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1662
1678
|
});
|
|
1679
|
+
// One member's call at a time.
|
|
1680
|
+
const listened = teamMembers.members.filter((member) => isPlainObject(member) && member.listening !== undefined);
|
|
1681
|
+
if (listened.length > 1)
|
|
1682
|
+
into.add("team.listening.single", `${path}.members`, "a lead is on one member's call at a time");
|
|
1663
1683
|
}
|
|
1664
1684
|
export function validateSnapshot(snapshot, manifest, path = "snapshot", context = {}) {
|
|
1665
1685
|
const into = new Collector();
|
|
@@ -1685,21 +1705,8 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1685
1705
|
}
|
|
1686
1706
|
else {
|
|
1687
1707
|
const seen = new Set();
|
|
1688
|
-
let assisting;
|
|
1689
|
-
let listening;
|
|
1690
1708
|
snapshot.tasks.forEach((task, index) => {
|
|
1691
1709
|
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
|
|
1692
|
-
// A lead assists one call at a time, and listens to one at a time.
|
|
1693
|
-
if (isPlainObject(task) && task.assisting !== undefined) {
|
|
1694
|
-
if (assisting !== undefined)
|
|
1695
|
-
into.add("snapshot.assisting.single", `${path}.tasks[${index}].assisting`, "a lead assists one call at a time");
|
|
1696
|
-
assisting = index;
|
|
1697
|
-
}
|
|
1698
|
-
if (isPlainObject(task) && task.listening !== undefined) {
|
|
1699
|
-
if (listening !== undefined)
|
|
1700
|
-
into.add("snapshot.listening.single", `${path}.tasks[${index}].listening`, "a lead listens to one call at a time");
|
|
1701
|
-
listening = index;
|
|
1702
|
-
}
|
|
1703
1710
|
if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
|
|
1704
1711
|
if (seen.has(task.assignmentId))
|
|
1705
1712
|
into.add("task.assignmentId.unique", `${path}.tasks[${index}].assignmentId`, `duplicate assignment id: ${task.assignmentId}`);
|
|
@@ -1707,14 +1714,6 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1707
1714
|
}
|
|
1708
1715
|
});
|
|
1709
1716
|
}
|
|
1710
|
-
// A lead listens while holding no work of their own: a listening task is the only task.
|
|
1711
|
-
if (Array.isArray(snapshot.tasks) && snapshot.tasks.length > 1) {
|
|
1712
|
-
snapshot.tasks.forEach((task, index) => {
|
|
1713
|
-
if (isPlainObject(task) && task.listening !== undefined) {
|
|
1714
|
-
into.add("snapshot.listening.alone", `${path}.tasks[${index}].listening`, "a lead listens only while holding no task of their own");
|
|
1715
|
-
}
|
|
1716
|
-
});
|
|
1717
|
-
}
|
|
1718
1717
|
into.violations.push(...validateBreakStatus(snapshot.break, snapshot.tasks, path));
|
|
1719
1718
|
// Presence is the permission, and it cuts both ways: data a provider never declared a
|
|
1720
1719
|
// capability for is data Omni would show against a control the agent does not have.
|
|
@@ -1755,13 +1754,17 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1755
1754
|
into.add("snapshot.calendar.shape", `${path}.scheduledActivities`, "scheduledActivities must be an array when present");
|
|
1756
1755
|
}
|
|
1757
1756
|
}
|
|
1758
|
-
// The login is the permission: a lead's snapshot carries a team member list, `[]` included
|
|
1759
|
-
// direction -- a team member list to a login that does
|
|
1760
|
-
|
|
1761
|
-
|
|
1757
|
+
// The login is the permission: a lead's snapshot carries a team member list, `members: []` included,
|
|
1758
|
+
// while the team feature is on. The other direction -- a team member list to a login that does
|
|
1759
|
+
// not lead, or to a lead who turned the feature off -- is the team member list's own rule.
|
|
1760
|
+
if (context.capabilities?.lead === true && context.leadFeatures !== false && snapshot.team === undefined) {
|
|
1761
|
+
into.add("team.required", `${path}.team`, "the login declares lead and the team feature is on, so every snapshot carries a team member list: members: [] when nobody is in it");
|
|
1762
|
+
}
|
|
1763
|
+
if (context.leadFeatures === false && snapshot.team !== undefined) {
|
|
1764
|
+
into.add("team.unexpected", `${path}.team`, "the lead turned the team feature off, so nothing of the team reaches them");
|
|
1762
1765
|
}
|
|
1763
1766
|
if (snapshot.team !== undefined)
|
|
1764
|
-
validateTeamMembersInto(snapshot.team, `${path}.team`, { ...context, levels }, into);
|
|
1767
|
+
validateTeamMembersInto(snapshot.team, `${path}.team`, { ...context, levels, channel: isChannel(channel) ? channel : undefined }, into);
|
|
1765
1768
|
return into.violations;
|
|
1766
1769
|
}
|
|
1767
1770
|
// ---------------------------------------------------------------------------
|
|
@@ -1797,8 +1800,6 @@ function validateTaskOutcome(value, path, into) {
|
|
|
1797
1800
|
// Only the phases in which a task is still waiting on somebody can expire.
|
|
1798
1801
|
into.oneOf(value.phase, EXPIRABLE_PHASES, "event.taskEnded.outcome.expired", `${path}.phase`);
|
|
1799
1802
|
break;
|
|
1800
|
-
case "left":
|
|
1801
|
-
break;
|
|
1802
1803
|
case "failed":
|
|
1803
1804
|
if (!isPlainObject(value.failure)) {
|
|
1804
1805
|
into.add("event.taskEnded.outcome.failed", `${path}.failure`, "a failed outcome must carry a failure");
|
|
@@ -1948,7 +1949,17 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1948
1949
|
validateQueueSummary(event.summary, `${at}.summary`, into);
|
|
1949
1950
|
break;
|
|
1950
1951
|
case "team-updated":
|
|
1951
|
-
|
|
1952
|
+
into.require(context.capabilities === undefined || context.capabilities.lead === true, "event.team.capability", `${at}.team`, "team-updated reaches a login that declares lead, and nobody else");
|
|
1953
|
+
into.require(context.leadFeatures !== false, "event.team.features", `${at}.team`, "the lead turned the team feature off, so nothing of the team reaches them");
|
|
1954
|
+
validateTeamMembersInto(event.team, `${at}.team`, { ...context, levels, channel: isChannel(channel) ? channel : undefined }, into);
|
|
1955
|
+
break;
|
|
1956
|
+
case "team-media-started":
|
|
1957
|
+
case "team-media-ended":
|
|
1958
|
+
// The lead's channel in a member's call: audio the application opens on the member's assignment.
|
|
1959
|
+
into.require(channel === "voice", "event.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
|
|
1960
|
+
into.require(context.capabilities === undefined || context.capabilities.lead === true, "event.teamMedia.capability", `${at}.type`, "team media reaches a login that declares lead, and nobody else");
|
|
1961
|
+
into.require(isUserId(event.memberId), "event.teamMedia.memberId", `${at}.memberId`, "team media names the member whose call the lead is on");
|
|
1962
|
+
into.require(isAssignmentId(event.assignmentId), "event.teamMedia.assignmentId", `${at}.assignmentId`, "team media names the member's assignment");
|
|
1952
1963
|
break;
|
|
1953
1964
|
case "contacts-updated":
|
|
1954
1965
|
into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
|
|
@@ -2236,7 +2247,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
2236
2247
|
// Commands.
|
|
2237
2248
|
// ---------------------------------------------------------------------------
|
|
2238
2249
|
const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
|
|
2239
|
-
const LEAD_ASSIST_ACTIONS = ["request", "cancel"
|
|
2250
|
+
const LEAD_ASSIST_ACTIONS = ["request", "cancel"];
|
|
2240
2251
|
const CONFERENCE_ACTIONS = ["add", "remove"];
|
|
2241
2252
|
/** Validate the exact interaction/assignment target before provider command dispatch. */
|
|
2242
2253
|
export function validateTaskCommandRequest(request, task, path = "request", taskContext) {
|
|
@@ -2435,6 +2446,10 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2435
2446
|
offered("endCall");
|
|
2436
2447
|
interaction();
|
|
2437
2448
|
break;
|
|
2449
|
+
case "terminate-call":
|
|
2450
|
+
offered("terminateCall");
|
|
2451
|
+
interaction();
|
|
2452
|
+
break;
|
|
2438
2453
|
case "recording": {
|
|
2439
2454
|
if (!offered("recording"))
|
|
2440
2455
|
break;
|
|
@@ -2469,14 +2484,9 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2469
2484
|
break;
|
|
2470
2485
|
case "lead-assist":
|
|
2471
2486
|
interaction();
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
into.require(isPlainObject(task.leadAssist) && task.leadAssist.stage === "requested", "command.leadAssist.requested", path, "cancel needs a request standing: leadAssist with stage requested");
|
|
2476
|
-
}
|
|
2477
|
-
}
|
|
2478
|
-
else {
|
|
2479
|
-
into.require(task.assisting !== undefined, "command.leadAssist.assisting", path, `${describeValue(command.action)} is the lead's own act on a call they joined: the task carries assisting`);
|
|
2487
|
+
offered("leadAssist");
|
|
2488
|
+
if (command.action === "cancel") {
|
|
2489
|
+
into.require(isPlainObject(task.leadAssist) && task.leadAssist.stage === "requested", "command.leadAssist.requested", path, "cancel needs a request standing: leadAssist with stage requested");
|
|
2480
2490
|
}
|
|
2481
2491
|
break;
|
|
2482
2492
|
case "conference": {
|
|
@@ -2540,13 +2550,10 @@ const RESULT_STATUSES = {
|
|
|
2540
2550
|
commitBreak: { success: "committed", failure: "failed" },
|
|
2541
2551
|
cancelBreak: { success: "cancelled", failure: "failed" },
|
|
2542
2552
|
endBreak: { success: "ended", failure: "failed" },
|
|
2543
|
-
|
|
2544
|
-
executeTeamLeadAssist: { success: "applied", failure: "failed" },
|
|
2545
|
-
executeTeamListen: { success: "applied", failure: "failed" },
|
|
2553
|
+
executeTeam: { success: "applied", failure: "failed" },
|
|
2546
2554
|
openMedia: { success: "opened", failure: "unavailable" },
|
|
2547
2555
|
setPreference: { success: "applied", failure: "failed" },
|
|
2548
2556
|
recordStep: { success: "recorded", failure: "failed" },
|
|
2549
|
-
executeTeamPolicy: { success: "applied", failure: "failed" },
|
|
2550
2557
|
};
|
|
2551
2558
|
/**
|
|
2552
2559
|
* Validates what a connection method answered. A result is untrusted for the same reason a
|
|
@@ -2683,43 +2690,9 @@ function validateUserCapabilitiesInto(value, path, into, levels) {
|
|
|
2683
2690
|
validatePreferencesInto(declared, `${path}.preferences`, into, levels);
|
|
2684
2691
|
continue;
|
|
2685
2692
|
}
|
|
2686
|
-
if (name === "team") {
|
|
2687
|
-
if (!isPlainObject(declared)) {
|
|
2688
|
-
into.add("authentication.capability.team.shape", `${path}.team`, "team names what the lead may do: an object, {} for a lead with no controls");
|
|
2689
|
-
continue;
|
|
2690
|
-
}
|
|
2691
|
-
for (const [control, on] of Object.entries(declared)) {
|
|
2692
|
-
if (on === undefined)
|
|
2693
|
-
continue;
|
|
2694
|
-
if (!into.require(TEAM_CAPABILITIES.includes(control), "authentication.capability.team.unknown", `${path}.team.${control}`, `unsupported team capability: ${control}`))
|
|
2695
|
-
continue;
|
|
2696
|
-
if (control === "listeningControl") {
|
|
2697
|
-
validateListeningControl(on, `${path}.team.listeningControl`, into);
|
|
2698
|
-
continue;
|
|
2699
|
-
}
|
|
2700
|
-
into.require(on === true, "authentication.capability.value", `${path}.team.${control}`, `${control} is declared by presence: send true or omit it`);
|
|
2701
|
-
}
|
|
2702
|
-
continue;
|
|
2703
|
-
}
|
|
2704
2693
|
into.require(declared === true, "authentication.capability.value", `${path}.${name}`, `${name} is declared by presence: send true or omit it`);
|
|
2705
2694
|
}
|
|
2706
2695
|
}
|
|
2707
|
-
/**
|
|
2708
|
-
* The modes a lead may listen in, as a list: coach and join-call begin with silent listening, so a list
|
|
2709
|
-
* without `listen` names modes the lead could never reach.
|
|
2710
|
-
*/
|
|
2711
|
-
function validateListeningControl(value, path, into) {
|
|
2712
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
2713
|
-
into.add("authentication.capability.team.listeningControl.shape", path, "listeningControl lists the modes this lead may listen in: a non-empty array, or omitted for a lead who may not");
|
|
2714
|
-
return;
|
|
2715
|
-
}
|
|
2716
|
-
value.forEach((mode, index) => {
|
|
2717
|
-
if (into.oneOf(mode, LISTENING_MODES, "authentication.capability.team.listeningControl.mode", `${path}[${index}]`) && value.indexOf(mode) !== index) {
|
|
2718
|
-
into.add("authentication.capability.team.listeningControl.unique", `${path}[${index}]`, `duplicate listen mode: ${describeValue(mode)}`);
|
|
2719
|
-
}
|
|
2720
|
-
});
|
|
2721
|
-
into.require(value.includes("listen"), "authentication.capability.team.listeningControl.listen", path, "coach and join-call begin with silent listening: a lead who may listen in any mode may listen");
|
|
2722
|
-
}
|
|
2723
2696
|
/**
|
|
2724
2697
|
* A failure an authentication reports: on a `rejected` start or completion, or on an `expired`
|
|
2725
2698
|
* state. A provider names its own codes freely; an `omni.` code is the contract's and has to be one
|