@xema/omni-protocol 0.1.25 → 0.1.27
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/README.md +1 -1
- package/dist/index.d.ts +78 -72
- package/dist/index.js +7 -7
- package/dist/testing.d.ts +3 -3
- package/dist/testing.js +27 -27
- package/dist/validation.d.ts +10 -10
- package/dist/validation.js +97 -100
- package/guide.md +160 -150
- 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 { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion,
|
|
12
|
+
import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, 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") {
|
|
@@ -36,7 +36,7 @@ export function assertNoViolations(violations, summary) {
|
|
|
36
36
|
*/
|
|
37
37
|
const membersOf = (members) => Object.keys(members);
|
|
38
38
|
const CHANNELS = membersOf({ voice: true, chat: true, email: true });
|
|
39
|
-
const
|
|
39
|
+
const TRANSPORT_RECOVERIES = membersOf({ reconnect: true, reauthenticate: true });
|
|
40
40
|
const TASK_PHASES = membersOf({
|
|
41
41
|
pending: true, confirmed: true, preparing: true, "in-progress": true, paused: true, completing: true,
|
|
42
42
|
});
|
|
@@ -44,7 +44,7 @@ const COMPLETION_MODES = membersOf({ "agent-command": true, "provider-automatic"
|
|
|
44
44
|
const ACCEPTANCE_MODES = membersOf({
|
|
45
45
|
"no-preference": true, "require-agent-acceptance": true, "require-automatic-acceptance": true,
|
|
46
46
|
});
|
|
47
|
-
const
|
|
47
|
+
const TRANSPORT_STATUSES = membersOf({ connecting: true, active: true, error: true });
|
|
48
48
|
const AUTHENTICATION_METHODS = membersOf({ "browser-sso": true, credentials: true });
|
|
49
49
|
const AUTHENTICATION_STATUSES = membersOf({
|
|
50
50
|
"signed-out": true, authenticating: true, authenticated: true, refreshing: true, expired: true,
|
|
@@ -59,7 +59,7 @@ const HANDLING_STEPS = membersOf({
|
|
|
59
59
|
queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
|
|
60
60
|
});
|
|
61
61
|
const DESTINATION_KINDS = membersOf({ queue: true, agent: true, external: true });
|
|
62
|
-
const
|
|
62
|
+
const CUSTOM_UI_CONTROLS = membersOf({ button: true, toggle: true, "menu-item": true });
|
|
63
63
|
const CUSTOM_UI_PLACEMENTS = membersOf({ primary: true, secondary: true, overflow: true });
|
|
64
64
|
const NOTES_POLICIES = membersOf({ required: true, optional: true, hidden: true });
|
|
65
65
|
const ACCESS_MODES = membersOf({ "allow-all": true, "block-all": true });
|
|
@@ -165,7 +165,7 @@ export function validateContact(contact, path = "contact") {
|
|
|
165
165
|
validateContactInto(contact, path, into);
|
|
166
166
|
return into.violations;
|
|
167
167
|
}
|
|
168
|
-
function validateContactInto(contact, path, into,
|
|
168
|
+
function validateContactInto(contact, path, into, levels) {
|
|
169
169
|
if (!isPlainObject(contact)) {
|
|
170
170
|
into.add("contact.shape", path, "a contact must be an object");
|
|
171
171
|
return;
|
|
@@ -174,7 +174,7 @@ function validateContactInto(contact, path, into, tiers) {
|
|
|
174
174
|
if (contact[field] === undefined)
|
|
175
175
|
continue;
|
|
176
176
|
if ((field === "number" || field === "email") && isLocked(contact[field])) {
|
|
177
|
-
validateLockedInto(contact[field], `contact.${field}.locked`, `${path}.${field}`,
|
|
177
|
+
validateLockedInto(contact[field], `contact.${field}.locked`, `${path}.${field}`, levels, into);
|
|
178
178
|
continue;
|
|
179
179
|
}
|
|
180
180
|
into.filled(contact[field], `contact.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
|
|
@@ -200,14 +200,14 @@ function validateScheduledActivityInto(activity, path, into) {
|
|
|
200
200
|
into.require(Date.parse(activity.endsAt) >= Date.parse(activity.startsAt), "activity.endsAt.order", `${path}.endsAt`, "endsAt must not precede startsAt");
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
|
-
if (activity.
|
|
204
|
-
validateContactInto(activity.
|
|
203
|
+
if (activity.party !== undefined)
|
|
204
|
+
validateContactInto(activity.party, `${path}.party`, into);
|
|
205
205
|
validateAttributes(activity.attributes, `${path}.attributes`, into);
|
|
206
206
|
}
|
|
207
207
|
// ---------------------------------------------------------------------------
|
|
208
208
|
// Manifest.
|
|
209
209
|
// ---------------------------------------------------------------------------
|
|
210
|
-
function
|
|
210
|
+
function validateBrowserAccess(value, path, into) {
|
|
211
211
|
if (!isPlainObject(value)) {
|
|
212
212
|
into.add("manifest.personalBrowser.access.shape", path, "an access policy must be an object");
|
|
213
213
|
return;
|
|
@@ -246,7 +246,7 @@ function validateIdleCapabilities(value, channel, path, into) {
|
|
|
246
246
|
into.add("manifest.personalBrowser.shape", `${path}.personalBrowser`, "personalBrowser must be an object when present");
|
|
247
247
|
}
|
|
248
248
|
else {
|
|
249
|
-
|
|
249
|
+
validateBrowserAccess(browser.access, `${path}.personalBrowser.access`, into);
|
|
250
250
|
if (browser.accessPolicyScope !== undefined) {
|
|
251
251
|
into.oneOf(browser.accessPolicyScope, ACCESS_POLICY_SCOPES, "manifest.personalBrowser.accessPolicyScope", `${path}.personalBrowser.accessPolicyScope`);
|
|
252
252
|
}
|
|
@@ -257,7 +257,7 @@ function validateIdleCapabilities(value, channel, path, into) {
|
|
|
257
257
|
into.add("manifest.dial.shape", `${path}.dial`, "dial must be an object when present");
|
|
258
258
|
}
|
|
259
259
|
else {
|
|
260
|
-
into.oneOf(value.dial.
|
|
260
|
+
into.oneOf(value.dial.destinations, DIAL_DESTINATION_POLICIES, "manifest.dial.destinations", `${path}.dial.destinations`);
|
|
261
261
|
}
|
|
262
262
|
}
|
|
263
263
|
for (const flag of ["calendar", "contacts"]) {
|
|
@@ -312,34 +312,34 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
314
|
}
|
|
315
|
-
if (manifest.
|
|
316
|
-
if (!Array.isArray(manifest.
|
|
317
|
-
into.add("manifest.
|
|
315
|
+
if (manifest.orgLevels !== undefined) {
|
|
316
|
+
if (!Array.isArray(manifest.orgLevels)) {
|
|
317
|
+
into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
|
|
318
318
|
}
|
|
319
319
|
else {
|
|
320
320
|
const ids = new Set();
|
|
321
321
|
let wellFormed = true;
|
|
322
|
-
manifest.
|
|
323
|
-
const at = `${path}.
|
|
324
|
-
if (!isPlainObject(
|
|
325
|
-
into.add("manifest.
|
|
322
|
+
manifest.orgLevels.forEach((level, index) => {
|
|
323
|
+
const at = `${path}.orgLevels[${index}]`;
|
|
324
|
+
if (!isPlainObject(level)) {
|
|
325
|
+
into.add("manifest.orgLevel.shape", at, "each level must be an object with an id and a label");
|
|
326
326
|
wellFormed = false;
|
|
327
327
|
return;
|
|
328
328
|
}
|
|
329
|
-
if (into.filled(
|
|
330
|
-
if (ids.has(
|
|
331
|
-
into.add("manifest.
|
|
329
|
+
if (into.filled(level.id, "manifest.orgLevel.id", `${at}.id`, "a level needs an id")) {
|
|
330
|
+
if (ids.has(level.id)) {
|
|
331
|
+
into.add("manifest.orgLevel.unique", `${at}.id`, `duplicate level: ${level.id}`);
|
|
332
332
|
wellFormed = false;
|
|
333
333
|
}
|
|
334
|
-
ids.add(
|
|
334
|
+
ids.add(level.id);
|
|
335
335
|
}
|
|
336
336
|
else
|
|
337
337
|
wellFormed = false;
|
|
338
|
-
if (!into.filled(
|
|
338
|
+
if (!into.filled(level.label, "manifest.orgLevel.label", `${at}.label`, "a level needs the label a desk shows for it"))
|
|
339
339
|
wellFormed = false;
|
|
340
340
|
});
|
|
341
341
|
if (wellFormed && !ids.has("person")) {
|
|
342
|
-
into.add("manifest.
|
|
342
|
+
into.add("manifest.orgLevels.person", `${path}.orgLevels`, "a declared ladder states the whole ladder and must include person, the subject of every resolution");
|
|
343
343
|
}
|
|
344
344
|
}
|
|
345
345
|
}
|
|
@@ -460,7 +460,7 @@ function validateCustomCapabilities(value, path, into) {
|
|
|
460
460
|
into.add("task.custom.ui", `${at}.ui`, "a custom capability needs a ui description");
|
|
461
461
|
return;
|
|
462
462
|
}
|
|
463
|
-
into.oneOf(custom.ui.
|
|
463
|
+
into.oneOf(custom.ui.control, CUSTOM_UI_CONTROLS, "task.custom.ui.control", `${at}.ui.control`);
|
|
464
464
|
into.filled(custom.ui.label, "task.custom.ui.label", `${at}.ui.label`, "a custom control needs a label");
|
|
465
465
|
into.oneOf(custom.ui.placement, CUSTOM_UI_PLACEMENTS, "task.custom.ui.placement", `${at}.ui.placement`);
|
|
466
466
|
if (custom.ui.render !== undefined)
|
|
@@ -485,48 +485,48 @@ function validateCustomCapabilities(value, path, into) {
|
|
|
485
485
|
}
|
|
486
486
|
});
|
|
487
487
|
}
|
|
488
|
-
const
|
|
488
|
+
const DEFAULT_LEVEL_IDS = DEFAULT_LEVELS.map(level => level.id);
|
|
489
489
|
const POLICY_SETTINGS = membersOf({ on: true, off: true, agent: true });
|
|
490
490
|
const POLICY_KEYS = new Set([
|
|
491
491
|
...TASK_CAPABILITIES.voice, ...TASK_CAPABILITIES.chat, ...TASK_CAPABILITIES.email, "dial",
|
|
492
492
|
].filter(name => name !== "browsers" && name !== "dispositions" && name !== "custom"));
|
|
493
493
|
const AGENT_SETTABLE = /^(hold|mute|skill:.+)$/;
|
|
494
494
|
const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
|
|
495
|
-
/** The
|
|
496
|
-
const
|
|
497
|
-
/** `lockedBy`: a declared
|
|
498
|
-
function validateLockedByInto(value, rule, path,
|
|
499
|
-
if (!into.filled(value, rule, path, "lockedBy names the
|
|
495
|
+
/** The level ids in force: the manifest's, or the defaults when the caller holds no manifest. */
|
|
496
|
+
const levelIds = (levels) => levels ?? DEFAULT_LEVEL_IDS;
|
|
497
|
+
/** `lockedBy`: a declared level other than `person`, who never locks their own value. */
|
|
498
|
+
function validateLockedByInto(value, rule, path, levels, into) {
|
|
499
|
+
if (!into.filled(value, rule, path, "lockedBy names the level that locked it"))
|
|
500
500
|
return;
|
|
501
501
|
into.require(value !== "person", `${rule}.person`, path, "a person never locks their own value");
|
|
502
|
-
into.require(
|
|
502
|
+
into.require(levelIds(levels).includes(value), `${rule}.unknown`, path, `${String(value)} is not a level this manifest declares: in force are ${levelIds(levels).join(", ")}`);
|
|
503
503
|
}
|
|
504
504
|
/** `{ lockedBy, reason? }` standing in for a value: who locked it, and a reason if given. */
|
|
505
|
-
function validateLockedInto(value, rule, path,
|
|
506
|
-
validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`,
|
|
505
|
+
function validateLockedInto(value, rule, path, levels, into) {
|
|
506
|
+
validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, levels, into);
|
|
507
507
|
if (value.reason !== undefined)
|
|
508
508
|
into.filled(value.reason, `${rule}.reason`, `${path}.reason`, "a reason must not be empty when present");
|
|
509
509
|
}
|
|
510
510
|
/** What every resolved value carries: who set it, and who locked it if anyone. */
|
|
511
|
-
function validateResolvedInto(value, rule, path,
|
|
512
|
-
if (into.filled(value.setBy, `${rule}.setBy`, `${path}.setBy`, "setBy names who stated the value: a
|
|
513
|
-
into.require(value.setBy === "
|
|
511
|
+
function validateResolvedInto(value, rule, path, levels, into) {
|
|
512
|
+
if (into.filled(value.setBy, `${rule}.setBy`, `${path}.setBy`, "setBy names who stated the value: a level, or provisioning")) {
|
|
513
|
+
into.require(value.setBy === "provider" || levelIds(levels).includes(value.setBy), `${rule}.setBy.unknown`, `${path}.setBy`, `${String(value.setBy)} is neither provisioning nor a level this manifest declares`);
|
|
514
514
|
}
|
|
515
515
|
if (value.lockedBy !== undefined)
|
|
516
|
-
validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`,
|
|
516
|
+
validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, levels, into);
|
|
517
517
|
if (value.reason !== undefined) {
|
|
518
518
|
into.filled(value.reason, `${rule}.reason`, `${path}.reason`, "a reason must not be empty when present");
|
|
519
519
|
into.require(value.lockedBy !== undefined, `${rule}.reason.unexpected`, `${path}.reason`, "a reason goes with lockedBy: it says why it was locked");
|
|
520
520
|
}
|
|
521
521
|
}
|
|
522
|
-
/** The
|
|
523
|
-
function
|
|
522
|
+
/** The level ids a manifest puts in force, for validators that receive one. */
|
|
523
|
+
function manifestLevels(manifest) {
|
|
524
524
|
if (!isPlainObject(manifest))
|
|
525
525
|
return undefined;
|
|
526
|
-
const declared = Array.isArray(manifest.
|
|
527
|
-
? manifest.
|
|
526
|
+
const declared = Array.isArray(manifest.orgLevels)
|
|
527
|
+
? manifest.orgLevels.filter((level) => isPlainObject(level) && typeof level.id === "string")
|
|
528
528
|
: undefined;
|
|
529
|
-
return
|
|
529
|
+
return effectiveLevels(declared).map(level => level.id);
|
|
530
530
|
}
|
|
531
531
|
const CUSTOM_RENDERS = membersOf({ inline: true, page: true });
|
|
532
532
|
const CREDENTIAL_FIELD_TYPES = membersOf({ text: true, password: true });
|
|
@@ -556,6 +556,9 @@ function validateBrowsers(value, path, into) {
|
|
|
556
556
|
names.add(browser.name);
|
|
557
557
|
}
|
|
558
558
|
into.filled(browser.purpose, "task.browser.purpose", `${at}.purpose`, "a browser needs a purpose");
|
|
559
|
+
if (browser.urlVisibility !== undefined) {
|
|
560
|
+
into.oneOf(browser.urlVisibility, URL_VISIBILITIES, "task.browser.urlVisibility", `${at}.urlVisibility`);
|
|
561
|
+
}
|
|
559
562
|
if (into.filled(browser.url, "task.browser.url", `${at}.url`, "a browser needs a url")) {
|
|
560
563
|
let scheme;
|
|
561
564
|
try {
|
|
@@ -569,7 +572,7 @@ function validateBrowsers(value, path, into) {
|
|
|
569
572
|
// Reuse and its scheme travel together. A reusing browser with no scheme would otherwise
|
|
570
573
|
// inherit whatever a host happened to default to, which is how two tasks end up sharing a
|
|
571
574
|
// session nobody intended. The guide names the rule for the missing case.
|
|
572
|
-
if (browser.
|
|
575
|
+
if (browser.sharedSession === true) {
|
|
573
576
|
if (browser.isolationScheme === undefined) {
|
|
574
577
|
into.add("task.browser.isolationScheme.required", `${at}.isolationScheme`, `a reusing browser must declare one of: ${ISOLATION_SCHEME_VALUES.join(", ")}`);
|
|
575
578
|
}
|
|
@@ -577,11 +580,11 @@ function validateBrowsers(value, path, into) {
|
|
|
577
580
|
into.require(ISOLATION_SCHEME_VALUES.includes(browser.isolationScheme), "task.browser.isolationScheme", `${at}.isolationScheme`, `an isolation scheme must be one of: ${ISOLATION_SCHEME_VALUES.join(", ")}`);
|
|
578
581
|
}
|
|
579
582
|
}
|
|
580
|
-
else if (browser.
|
|
581
|
-
into.require(browser.isolationScheme === undefined, "task.browser.isolationScheme.unexpected", `${at}.isolationScheme`, "a browser that does not
|
|
583
|
+
else if (browser.sharedSession === false) {
|
|
584
|
+
into.require(browser.isolationScheme === undefined, "task.browser.isolationScheme.unexpected", `${at}.isolationScheme`, "a browser that does not share its session must not declare an isolation scheme");
|
|
582
585
|
}
|
|
583
586
|
else {
|
|
584
|
-
into.add("task.browser.
|
|
587
|
+
into.add("task.browser.sharedSession", `${at}.sharedSession`, "a browser must say whether its session is shared across tasks");
|
|
585
588
|
}
|
|
586
589
|
});
|
|
587
590
|
}
|
|
@@ -649,7 +652,7 @@ function validateHandlingHistory(value, path, into) {
|
|
|
649
652
|
});
|
|
650
653
|
}
|
|
651
654
|
/** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
|
|
652
|
-
const TASK_MEDIA_STATES = membersOf({
|
|
655
|
+
const TASK_MEDIA_STATES = membersOf({ started: true, ended: true });
|
|
653
656
|
/** Real-time media is a voice affair, and its state is one of two words. */
|
|
654
657
|
function validateTaskMedia(value, channel, path, into) {
|
|
655
658
|
if (value === undefined)
|
|
@@ -674,7 +677,7 @@ function validateConsultation(value, channel, path, into) {
|
|
|
674
677
|
if (value.since !== undefined)
|
|
675
678
|
into.timestamp(value.since, "task.consultation.since", `${path}.since`);
|
|
676
679
|
}
|
|
677
|
-
const
|
|
680
|
+
const LEAD_STAGES = membersOf({ requested: true, joined: true });
|
|
678
681
|
/** The agent's request for a lead. Voice only; `joined` names the lead, `requested` cannot. */
|
|
679
682
|
function validateLead(value, channel, path, into) {
|
|
680
683
|
if (value === undefined)
|
|
@@ -685,8 +688,8 @@ function validateLead(value, channel, path, into) {
|
|
|
685
688
|
into.add("task.lead.shape", path, "lead must be an object when present");
|
|
686
689
|
return;
|
|
687
690
|
}
|
|
688
|
-
if (into.oneOf(value.
|
|
689
|
-
if (value.
|
|
691
|
+
if (into.oneOf(value.stage, LEAD_STAGES, "task.lead.stage", `${path}.stage`)) {
|
|
692
|
+
if (value.stage === "joined") {
|
|
690
693
|
into.require(isUserId(value.leadId), "task.lead.leadId", `${path}.leadId`, "a joined lead is named by their user id");
|
|
691
694
|
}
|
|
692
695
|
else {
|
|
@@ -730,11 +733,11 @@ function validateTaskInto(task, context, path, into) {
|
|
|
730
733
|
// The allowance is coupled to the mode: a provider that will complete the task itself is going
|
|
731
734
|
// to act on the allowance, so it must state one; a provider waiting for `complete` may omit it
|
|
732
735
|
// to say it imposes no deadline. Present, it is a duration either way.
|
|
733
|
-
if (task.
|
|
734
|
-
into.require(task.completionMode !== "provider-automatic", "task.
|
|
736
|
+
if (task.wrapAllowance === undefined) {
|
|
737
|
+
into.require(task.completionMode !== "provider-automatic", "task.wrapAllowance.required", `${path}.wrapAllowance`, "provider-automatic completion needs an allowance to act on");
|
|
735
738
|
}
|
|
736
739
|
else {
|
|
737
|
-
into.require(isDurationSeconds(task.
|
|
740
|
+
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");
|
|
738
741
|
}
|
|
739
742
|
// The channel is fixed per provider by its manifest, so a task claiming another one is a
|
|
740
743
|
// task Omni would render with the wrong controls.
|
|
@@ -742,8 +745,8 @@ function validateTaskInto(task, context, path, into) {
|
|
|
742
745
|
if (task.reference !== undefined) {
|
|
743
746
|
into.filled(task.reference, "task.reference", `${path}.reference`, "a reference must not be empty when present");
|
|
744
747
|
}
|
|
745
|
-
if (task.
|
|
746
|
-
validateContactInto(task.
|
|
748
|
+
if (task.party !== undefined)
|
|
749
|
+
validateContactInto(task.party, `${path}.party`, into, context.levels);
|
|
747
750
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
748
751
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into);
|
|
749
752
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
|
|
@@ -775,7 +778,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
775
778
|
// queue provides -- browsers, dispositions, custom controls -- is content, not a control.
|
|
776
779
|
if (isLocked(declared)) {
|
|
777
780
|
if (into.require(name !== "browsers" && name !== "dispositions" && name !== "custom", "task.capability.locked.unexpected", `${path}.capabilities.${name}`, `${name} is what the queue provides, not a control anyone locks`)) {
|
|
778
|
-
validateLockedInto(declared, "task.capability.locked", `${path}.capabilities.${name}`, context.
|
|
781
|
+
validateLockedInto(declared, "task.capability.locked", `${path}.capabilities.${name}`, context.levels, into);
|
|
779
782
|
}
|
|
780
783
|
continue;
|
|
781
784
|
}
|
|
@@ -826,15 +829,15 @@ function validateBreakState(value, path, into) {
|
|
|
826
829
|
return;
|
|
827
830
|
}
|
|
828
831
|
into.oneOf(value.approval, BREAK_APPROVALS, "break.approval", `${path}.approval`);
|
|
829
|
-
into.require(typeof value.
|
|
832
|
+
into.require(typeof value.mayAsk === "boolean", "break.mayAsk", `${path}.mayAsk`, "mayAsk says whether the agent may ask for a break: a boolean");
|
|
830
833
|
for (const field of ["refusedReason", "decisionReason"]) {
|
|
831
834
|
if (value[field] !== undefined) {
|
|
832
835
|
into.filled(value[field], `break.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
|
|
833
836
|
}
|
|
834
837
|
}
|
|
835
|
-
// The refusal is the reason the control is withdrawn; beside `
|
|
838
|
+
// The refusal is the reason the control is withdrawn; beside `mayAsk: true` it explains nothing.
|
|
836
839
|
if (value.refusedReason !== undefined) {
|
|
837
|
-
into.require(value.
|
|
840
|
+
into.require(value.mayAsk !== true, "break.refusedReason.mayAsk", `${path}.refusedReason`, "refusedReason is shown when mayAsk is false; omit it while the agent may ask");
|
|
838
841
|
}
|
|
839
842
|
if (value.retryAfterMs !== undefined) {
|
|
840
843
|
into.require(typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) && value.retryAfterMs >= 0, "break.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
|
|
@@ -910,7 +913,7 @@ function validateTeamRosterInto(roster, path, context, into) {
|
|
|
910
913
|
}
|
|
911
914
|
}
|
|
912
915
|
if (roster.policies !== undefined)
|
|
913
|
-
validateTeamPoliciesInto(roster.policies, `${path}.policies`, into, context.
|
|
916
|
+
validateTeamPoliciesInto(roster.policies, `${path}.policies`, into, context.levels);
|
|
914
917
|
if (roster.requests === undefined) {
|
|
915
918
|
// `[]` says nobody is asking; omission says the lead may not be asked. A login that may be
|
|
916
919
|
// asked therefore always carries the list.
|
|
@@ -986,11 +989,11 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
986
989
|
return into.violations;
|
|
987
990
|
}
|
|
988
991
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
989
|
-
const
|
|
990
|
-
into.oneOf(snapshot.
|
|
991
|
-
if (into.filled(snapshot.
|
|
992
|
-
&& context.
|
|
993
|
-
into.require(snapshot.
|
|
992
|
+
const levels = context.levels ?? manifestLevels(manifest);
|
|
993
|
+
into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.status`);
|
|
994
|
+
if (into.filled(snapshot.loginId, "snapshot.loginId", `${path}.loginId`, "a snapshot needs the login id it belongs to")
|
|
995
|
+
&& context.loginId !== undefined) {
|
|
996
|
+
into.require(snapshot.loginId === context.loginId, "snapshot.loginId.mismatch", `${path}.loginId`, `a snapshot for session ${String(snapshot.loginId)} on a login whose session is ${context.loginId}`);
|
|
994
997
|
}
|
|
995
998
|
validateBreakState(snapshot.break, `${path}.break`, into);
|
|
996
999
|
// The count is the provider's confirmation of how much work it answered with. Stated, never
|
|
@@ -1006,7 +1009,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1006
1009
|
const seen = new Set();
|
|
1007
1010
|
let assisting;
|
|
1008
1011
|
snapshot.tasks.forEach((task, index) => {
|
|
1009
|
-
validateTaskInto(task, { channel,
|
|
1012
|
+
validateTaskInto(task, { channel, levels }, `${path}.tasks[${index}]`, into);
|
|
1010
1013
|
// A lead assists one call at a time.
|
|
1011
1014
|
if (isPlainObject(task) && task.assisting !== undefined) {
|
|
1012
1015
|
if (assisting !== undefined)
|
|
@@ -1071,7 +1074,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1071
1074
|
into.add("team.required", `${path}.team`, "the login declares capabilities.team, so every snapshot carries a roster: [] when nobody is in it");
|
|
1072
1075
|
}
|
|
1073
1076
|
if (snapshot.team !== undefined)
|
|
1074
|
-
validateTeamRosterInto(snapshot.team, `${path}.team`, { ...context,
|
|
1077
|
+
validateTeamRosterInto(snapshot.team, `${path}.team`, { ...context, levels }, into);
|
|
1075
1078
|
return into.violations;
|
|
1076
1079
|
}
|
|
1077
1080
|
// ---------------------------------------------------------------------------
|
|
@@ -1114,7 +1117,7 @@ function validateTaskOutcome(value, path, into) {
|
|
|
1114
1117
|
into.add("event.taskEnded.outcome.type", `${path}.type`, `unsupported outcome: ${String(value.type)}`);
|
|
1115
1118
|
}
|
|
1116
1119
|
}
|
|
1117
|
-
function
|
|
1120
|
+
function validateQueueSummary(value, path, into) {
|
|
1118
1121
|
if (!isPlainObject(value)) {
|
|
1119
1122
|
into.add("event.summary.shape", path, "a provider summary must be an object");
|
|
1120
1123
|
return;
|
|
@@ -1155,11 +1158,11 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1155
1158
|
return into.violations;
|
|
1156
1159
|
}
|
|
1157
1160
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
1158
|
-
const
|
|
1161
|
+
const levels = context.levels ?? manifestLevels(manifest);
|
|
1159
1162
|
into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
|
|
1160
|
-
if (into.filled(envelope.
|
|
1161
|
-
&& context.
|
|
1162
|
-
into.require(envelope.
|
|
1163
|
+
if (into.filled(envelope.loginId, "event.loginId", `${path}.loginId`, "an event needs the login id it belongs to")
|
|
1164
|
+
&& context.loginId !== undefined) {
|
|
1165
|
+
into.require(envelope.loginId === context.loginId, "event.loginId.mismatch", `${path}.loginId`, `an event for session ${String(envelope.loginId)} on a login whose session is ${context.loginId}`);
|
|
1163
1166
|
}
|
|
1164
1167
|
const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
|
|
1165
1168
|
into.timestamp(envelope.occurredAt, "event.occurredAt", `${path}.occurredAt`);
|
|
@@ -1172,28 +1175,28 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1172
1175
|
switch (event.type) {
|
|
1173
1176
|
case "snapshot":
|
|
1174
1177
|
into.oneOf(event.reason, SNAPSHOT_REASONS, "event.snapshot.reason", `${at}.reason`);
|
|
1175
|
-
into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, { ...context,
|
|
1178
|
+
into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, { ...context, levels }));
|
|
1176
1179
|
break;
|
|
1177
|
-
case "
|
|
1178
|
-
into.oneOf(event.status,
|
|
1180
|
+
case "transport-status":
|
|
1181
|
+
into.oneOf(event.status, TRANSPORT_STATUSES, "event.transportStatus.status", `${at}.status`);
|
|
1179
1182
|
// An error says how to revive it; any other status has nothing to revive.
|
|
1180
1183
|
if (event.status === "error") {
|
|
1181
|
-
if (into.require(event.recovery !== undefined, "event.
|
|
1182
|
-
into.oneOf(event.recovery,
|
|
1184
|
+
if (into.require(event.recovery !== undefined, "event.transportStatus.recovery.required", `${at}.recovery`, "an error names its recovery: reconnect, or reauthenticate")) {
|
|
1185
|
+
into.oneOf(event.recovery, TRANSPORT_RECOVERIES, "event.transportStatus.recovery", `${at}.recovery`);
|
|
1183
1186
|
}
|
|
1184
1187
|
}
|
|
1185
1188
|
else {
|
|
1186
|
-
into.require(event.recovery === undefined, "event.
|
|
1189
|
+
into.require(event.recovery === undefined, "event.transportStatus.recovery.unexpected", `${at}.recovery`, "recovery goes with an error; nothing needs reviving here");
|
|
1187
1190
|
}
|
|
1188
1191
|
if (event.message !== undefined) {
|
|
1189
|
-
into.filled(event.message, "event.
|
|
1192
|
+
into.filled(event.message, "event.transportStatus.message", `${at}.message`, "a message must not be empty when present");
|
|
1190
1193
|
}
|
|
1191
1194
|
break;
|
|
1192
1195
|
case "break-state":
|
|
1193
1196
|
validateBreakState(event.break, `${at}.break`, into);
|
|
1194
1197
|
break;
|
|
1195
1198
|
case "task-offered":
|
|
1196
|
-
validateTaskInto(event.task, { channel,
|
|
1199
|
+
validateTaskInto(event.task, { channel, levels }, `${at}.task`, into);
|
|
1197
1200
|
// An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
|
|
1198
1201
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1199
1202
|
into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
|
|
@@ -1214,10 +1217,10 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1214
1217
|
}
|
|
1215
1218
|
break;
|
|
1216
1219
|
case "task-updated":
|
|
1217
|
-
validateTaskInto(event.task, { channel,
|
|
1220
|
+
validateTaskInto(event.task, { channel, levels }, `${at}.task`, into);
|
|
1218
1221
|
break;
|
|
1219
|
-
case "task-media-
|
|
1220
|
-
into.require(isTaskId(event.taskId), "event.
|
|
1222
|
+
case "task-media-started":
|
|
1223
|
+
into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
|
|
1221
1224
|
break;
|
|
1222
1225
|
case "task-media-ended":
|
|
1223
1226
|
into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
@@ -1235,11 +1238,11 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1235
1238
|
into.require(typeof event.html === "string", "event.announcement.html", `${at}.html`, "html must be a string when present");
|
|
1236
1239
|
}
|
|
1237
1240
|
break;
|
|
1238
|
-
case "
|
|
1239
|
-
|
|
1241
|
+
case "queue-summary":
|
|
1242
|
+
validateQueueSummary(event.summary, `${at}.summary`, into);
|
|
1240
1243
|
break;
|
|
1241
1244
|
case "team-updated":
|
|
1242
|
-
validateTeamRosterInto(event.team, `${at}.team`, { ...context,
|
|
1245
|
+
validateTeamRosterInto(event.team, `${at}.team`, { ...context, levels }, into);
|
|
1243
1246
|
break;
|
|
1244
1247
|
case "contacts-updated":
|
|
1245
1248
|
into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
|
|
@@ -1278,7 +1281,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1278
1281
|
// ---------------------------------------------------------------------------
|
|
1279
1282
|
const PREFERENCE_ID = /^(hold|mute|skill:.+)$/;
|
|
1280
1283
|
/** The choices left to the person, each with where it stands. */
|
|
1281
|
-
function validatePreferencesInto(value, path, into,
|
|
1284
|
+
function validatePreferencesInto(value, path, into, levels) {
|
|
1282
1285
|
if (!Array.isArray(value)) {
|
|
1283
1286
|
into.add("preferences.shape", path, "preferences must be an array");
|
|
1284
1287
|
return;
|
|
@@ -1297,11 +1300,11 @@ function validatePreferencesInto(value, path, into, tiers) {
|
|
|
1297
1300
|
}
|
|
1298
1301
|
into.filled(preference.label, "preference.label", `${at}.label`, "a preference needs a label");
|
|
1299
1302
|
into.require(typeof preference.enabled === "boolean", "preference.enabled", `${at}.enabled`, "a preference says where it stands");
|
|
1300
|
-
validateResolvedInto(preference, "preference", at,
|
|
1303
|
+
validateResolvedInto(preference, "preference", at, levels, into);
|
|
1301
1304
|
});
|
|
1302
1305
|
}
|
|
1303
1306
|
/** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
|
|
1304
|
-
function validateTeamPoliciesInto(value, path, into,
|
|
1307
|
+
function validateTeamPoliciesInto(value, path, into, levels) {
|
|
1305
1308
|
if (!isPlainObject(value)) {
|
|
1306
1309
|
into.add("team.policies.shape", path, "policies must be an object keyed by capability");
|
|
1307
1310
|
return;
|
|
@@ -1319,7 +1322,7 @@ function validateTeamPoliciesInto(value, path, into, tiers) {
|
|
|
1319
1322
|
if (into.oneOf(policy.setting, POLICY_SETTINGS, "team.policy.setting", `${at}.setting`)) {
|
|
1320
1323
|
into.require(policy.setting !== "agent" || AGENT_SETTABLE.test(key), "team.policy.agent", `${at}.setting`, `${key} is the team's, on or off; only hold, mute and skills may be left to the person`);
|
|
1321
1324
|
}
|
|
1322
|
-
validateResolvedInto(policy, "team.policy", at,
|
|
1325
|
+
validateResolvedInto(policy, "team.policy", at, levels, into);
|
|
1323
1326
|
into.require(policy.setBy !== "person", "team.policy.setBy", `${at}.setBy`, "a team policy is not set by a person");
|
|
1324
1327
|
}
|
|
1325
1328
|
}
|
|
@@ -1365,12 +1368,6 @@ export function validateHostReport(report, path = "host") {
|
|
|
1365
1368
|
return into.violations;
|
|
1366
1369
|
}
|
|
1367
1370
|
into.require(typeof report.online === "boolean", "host.online", `${path}.online`, "a host report says whether it has a network");
|
|
1368
|
-
if (!isPlainObject(report.browsers)) {
|
|
1369
|
-
into.add("host.browsers.shape", `${path}.browsers`, "a host report says what its chrome shows of a task browser's URL");
|
|
1370
|
-
}
|
|
1371
|
-
else {
|
|
1372
|
-
into.oneOf(report.browsers.urlVisibility, URL_VISIBILITIES, "host.browsers.urlVisibility", `${path}.browsers.urlVisibility`);
|
|
1373
|
-
}
|
|
1374
1371
|
if (report.audio === undefined)
|
|
1375
1372
|
return into.violations;
|
|
1376
1373
|
if (!isPlainObject(report.audio)) {
|
|
@@ -1474,7 +1471,7 @@ function validateUser(value, rule, path, into) {
|
|
|
1474
1471
|
into.require(isUserId(value.id), `${rule}.id`, `${path}.id`, "an identity needs a provider-issued user id");
|
|
1475
1472
|
into.filled(value.displayName, `${rule}.displayName`, `${path}.displayName`, "an identity needs a display name");
|
|
1476
1473
|
}
|
|
1477
|
-
function
|
|
1474
|
+
function validateUserCapabilitiesInto(value, path, into, levels) {
|
|
1478
1475
|
if (!isPlainObject(value)) {
|
|
1479
1476
|
into.add("authentication.capabilities.shape", path, "a usable login declares its capabilities: an object, {} when it has none");
|
|
1480
1477
|
return;
|
|
@@ -1488,7 +1485,7 @@ function validateSessionCapabilitiesInto(value, path, into, tiers) {
|
|
|
1488
1485
|
if (Array.isArray(declared) && declared.length === 0) {
|
|
1489
1486
|
into.add("authentication.capability.preferences.empty", `${path}.preferences`, "a login with nothing left to the person omits preferences rather than declaring an empty list");
|
|
1490
1487
|
}
|
|
1491
|
-
validatePreferencesInto(declared, `${path}.preferences`, into,
|
|
1488
|
+
validatePreferencesInto(declared, `${path}.preferences`, into, levels);
|
|
1492
1489
|
continue;
|
|
1493
1490
|
}
|
|
1494
1491
|
if (name === "team") {
|
|
@@ -1521,7 +1518,7 @@ export function validateAuthenticationState(state, path = "authentication", cont
|
|
|
1521
1518
|
// may carry an identity. Anything else is a state claiming knowledge it does not have.
|
|
1522
1519
|
if (state.status === "authenticated" || state.status === "refreshing") {
|
|
1523
1520
|
validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
|
|
1524
|
-
|
|
1521
|
+
validateUserCapabilitiesInto(state.capabilities, `${path}.capabilities`, into, context.levels);
|
|
1525
1522
|
}
|
|
1526
1523
|
else if (state.status === "expired") {
|
|
1527
1524
|
if (state.identity !== undefined)
|