@xema/omni-protocol 0.1.24 → 0.1.26
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 +69 -63
- package/dist/index.js +5 -5
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +31 -23
- package/dist/validation.d.ts +10 -10
- package/dist/validation.js +93 -90
- package/guide.md +165 -132
- 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`);
|
|
@@ -207,7 +207,7 @@ function validateScheduledActivityInto(activity, path, into) {
|
|
|
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 {
|
|
@@ -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.
|
|
@@ -743,7 +746,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
743
746
|
into.filled(task.reference, "task.reference", `${path}.reference`, "a reference must not be empty when present");
|
|
744
747
|
}
|
|
745
748
|
if (task.contact !== undefined)
|
|
746
|
-
validateContactInto(task.contact, `${path}.contact`, into, context.
|
|
749
|
+
validateContactInto(task.contact, `${path}.contact`, 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,13 +989,19 @@ 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);
|
|
999
|
+
// The count is the provider's confirmation of how much work it answered with. Stated, never
|
|
1000
|
+
// inferred: an unanswered or blank state lacks it, and cannot pass as a confirmed empty.
|
|
1001
|
+
if (into.require(typeof snapshot.taskCount === "number" && Number.isInteger(snapshot.taskCount) && snapshot.taskCount >= 0, "snapshot.taskCount", `${path}.taskCount`, "a snapshot states its task count: a whole number, zero or more")
|
|
1002
|
+
&& Array.isArray(snapshot.tasks)) {
|
|
1003
|
+
into.require(snapshot.taskCount === snapshot.tasks.length, "snapshot.taskCount.mismatch", `${path}.taskCount`, `taskCount says ${snapshot.taskCount} and tasks carries ${snapshot.tasks.length}: a count that does not reconcile is an answer nobody gave`);
|
|
1004
|
+
}
|
|
996
1005
|
if (!Array.isArray(snapshot.tasks)) {
|
|
997
1006
|
into.add("snapshot.tasks.shape", `${path}.tasks`, "a snapshot must carry a tasks array");
|
|
998
1007
|
}
|
|
@@ -1000,7 +1009,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1000
1009
|
const seen = new Set();
|
|
1001
1010
|
let assisting;
|
|
1002
1011
|
snapshot.tasks.forEach((task, index) => {
|
|
1003
|
-
validateTaskInto(task, { channel,
|
|
1012
|
+
validateTaskInto(task, { channel, levels }, `${path}.tasks[${index}]`, into);
|
|
1004
1013
|
// A lead assists one call at a time.
|
|
1005
1014
|
if (isPlainObject(task) && task.assisting !== undefined) {
|
|
1006
1015
|
if (assisting !== undefined)
|
|
@@ -1065,7 +1074,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1065
1074
|
into.add("team.required", `${path}.team`, "the login declares capabilities.team, so every snapshot carries a roster: [] when nobody is in it");
|
|
1066
1075
|
}
|
|
1067
1076
|
if (snapshot.team !== undefined)
|
|
1068
|
-
validateTeamRosterInto(snapshot.team, `${path}.team`, { ...context,
|
|
1077
|
+
validateTeamRosterInto(snapshot.team, `${path}.team`, { ...context, levels }, into);
|
|
1069
1078
|
return into.violations;
|
|
1070
1079
|
}
|
|
1071
1080
|
// ---------------------------------------------------------------------------
|
|
@@ -1149,11 +1158,11 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1149
1158
|
return into.violations;
|
|
1150
1159
|
}
|
|
1151
1160
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
1152
|
-
const
|
|
1161
|
+
const levels = context.levels ?? manifestLevels(manifest);
|
|
1153
1162
|
into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
|
|
1154
|
-
if (into.filled(envelope.
|
|
1155
|
-
&& context.
|
|
1156
|
-
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}`);
|
|
1157
1166
|
}
|
|
1158
1167
|
const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
|
|
1159
1168
|
into.timestamp(envelope.occurredAt, "event.occurredAt", `${path}.occurredAt`);
|
|
@@ -1166,28 +1175,28 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1166
1175
|
switch (event.type) {
|
|
1167
1176
|
case "snapshot":
|
|
1168
1177
|
into.oneOf(event.reason, SNAPSHOT_REASONS, "event.snapshot.reason", `${at}.reason`);
|
|
1169
|
-
into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, { ...context,
|
|
1178
|
+
into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, { ...context, levels }));
|
|
1170
1179
|
break;
|
|
1171
|
-
case "
|
|
1172
|
-
into.oneOf(event.status,
|
|
1180
|
+
case "transport-status":
|
|
1181
|
+
into.oneOf(event.status, TRANSPORT_STATUSES, "event.transportStatus.status", `${at}.status`);
|
|
1173
1182
|
// An error says how to revive it; any other status has nothing to revive.
|
|
1174
1183
|
if (event.status === "error") {
|
|
1175
|
-
if (into.require(event.recovery !== undefined, "event.
|
|
1176
|
-
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`);
|
|
1177
1186
|
}
|
|
1178
1187
|
}
|
|
1179
1188
|
else {
|
|
1180
|
-
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");
|
|
1181
1190
|
}
|
|
1182
1191
|
if (event.message !== undefined) {
|
|
1183
|
-
into.filled(event.message, "event.
|
|
1192
|
+
into.filled(event.message, "event.transportStatus.message", `${at}.message`, "a message must not be empty when present");
|
|
1184
1193
|
}
|
|
1185
1194
|
break;
|
|
1186
1195
|
case "break-state":
|
|
1187
1196
|
validateBreakState(event.break, `${at}.break`, into);
|
|
1188
1197
|
break;
|
|
1189
1198
|
case "task-offered":
|
|
1190
|
-
validateTaskInto(event.task, { channel,
|
|
1199
|
+
validateTaskInto(event.task, { channel, levels }, `${at}.task`, into);
|
|
1191
1200
|
// An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
|
|
1192
1201
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1193
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}`);
|
|
@@ -1208,10 +1217,10 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1208
1217
|
}
|
|
1209
1218
|
break;
|
|
1210
1219
|
case "task-updated":
|
|
1211
|
-
validateTaskInto(event.task, { channel,
|
|
1220
|
+
validateTaskInto(event.task, { channel, levels }, `${at}.task`, into);
|
|
1212
1221
|
break;
|
|
1213
|
-
case "task-media-
|
|
1214
|
-
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");
|
|
1215
1224
|
break;
|
|
1216
1225
|
case "task-media-ended":
|
|
1217
1226
|
into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
@@ -1233,7 +1242,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1233
1242
|
validateProviderSummary(event.summary, `${at}.summary`, into);
|
|
1234
1243
|
break;
|
|
1235
1244
|
case "team-updated":
|
|
1236
|
-
validateTeamRosterInto(event.team, `${at}.team`, { ...context,
|
|
1245
|
+
validateTeamRosterInto(event.team, `${at}.team`, { ...context, levels }, into);
|
|
1237
1246
|
break;
|
|
1238
1247
|
case "contacts-updated":
|
|
1239
1248
|
into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
|
|
@@ -1272,7 +1281,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1272
1281
|
// ---------------------------------------------------------------------------
|
|
1273
1282
|
const PREFERENCE_ID = /^(hold|mute|skill:.+)$/;
|
|
1274
1283
|
/** The choices left to the person, each with where it stands. */
|
|
1275
|
-
function validatePreferencesInto(value, path, into,
|
|
1284
|
+
function validatePreferencesInto(value, path, into, levels) {
|
|
1276
1285
|
if (!Array.isArray(value)) {
|
|
1277
1286
|
into.add("preferences.shape", path, "preferences must be an array");
|
|
1278
1287
|
return;
|
|
@@ -1291,11 +1300,11 @@ function validatePreferencesInto(value, path, into, tiers) {
|
|
|
1291
1300
|
}
|
|
1292
1301
|
into.filled(preference.label, "preference.label", `${at}.label`, "a preference needs a label");
|
|
1293
1302
|
into.require(typeof preference.enabled === "boolean", "preference.enabled", `${at}.enabled`, "a preference says where it stands");
|
|
1294
|
-
validateResolvedInto(preference, "preference", at,
|
|
1303
|
+
validateResolvedInto(preference, "preference", at, levels, into);
|
|
1295
1304
|
});
|
|
1296
1305
|
}
|
|
1297
1306
|
/** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
|
|
1298
|
-
function validateTeamPoliciesInto(value, path, into,
|
|
1307
|
+
function validateTeamPoliciesInto(value, path, into, levels) {
|
|
1299
1308
|
if (!isPlainObject(value)) {
|
|
1300
1309
|
into.add("team.policies.shape", path, "policies must be an object keyed by capability");
|
|
1301
1310
|
return;
|
|
@@ -1313,7 +1322,7 @@ function validateTeamPoliciesInto(value, path, into, tiers) {
|
|
|
1313
1322
|
if (into.oneOf(policy.setting, POLICY_SETTINGS, "team.policy.setting", `${at}.setting`)) {
|
|
1314
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`);
|
|
1315
1324
|
}
|
|
1316
|
-
validateResolvedInto(policy, "team.policy", at,
|
|
1325
|
+
validateResolvedInto(policy, "team.policy", at, levels, into);
|
|
1317
1326
|
into.require(policy.setBy !== "person", "team.policy.setBy", `${at}.setBy`, "a team policy is not set by a person");
|
|
1318
1327
|
}
|
|
1319
1328
|
}
|
|
@@ -1359,12 +1368,6 @@ export function validateHostReport(report, path = "host") {
|
|
|
1359
1368
|
return into.violations;
|
|
1360
1369
|
}
|
|
1361
1370
|
into.require(typeof report.online === "boolean", "host.online", `${path}.online`, "a host report says whether it has a network");
|
|
1362
|
-
if (!isPlainObject(report.browsers)) {
|
|
1363
|
-
into.add("host.browsers.shape", `${path}.browsers`, "a host report says what its chrome shows of a task browser's URL");
|
|
1364
|
-
}
|
|
1365
|
-
else {
|
|
1366
|
-
into.oneOf(report.browsers.urlVisibility, URL_VISIBILITIES, "host.browsers.urlVisibility", `${path}.browsers.urlVisibility`);
|
|
1367
|
-
}
|
|
1368
1371
|
if (report.audio === undefined)
|
|
1369
1372
|
return into.violations;
|
|
1370
1373
|
if (!isPlainObject(report.audio)) {
|
|
@@ -1468,7 +1471,7 @@ function validateUser(value, rule, path, into) {
|
|
|
1468
1471
|
into.require(isUserId(value.id), `${rule}.id`, `${path}.id`, "an identity needs a provider-issued user id");
|
|
1469
1472
|
into.filled(value.displayName, `${rule}.displayName`, `${path}.displayName`, "an identity needs a display name");
|
|
1470
1473
|
}
|
|
1471
|
-
function
|
|
1474
|
+
function validateUserCapabilitiesInto(value, path, into, levels) {
|
|
1472
1475
|
if (!isPlainObject(value)) {
|
|
1473
1476
|
into.add("authentication.capabilities.shape", path, "a usable login declares its capabilities: an object, {} when it has none");
|
|
1474
1477
|
return;
|
|
@@ -1482,7 +1485,7 @@ function validateSessionCapabilitiesInto(value, path, into, tiers) {
|
|
|
1482
1485
|
if (Array.isArray(declared) && declared.length === 0) {
|
|
1483
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");
|
|
1484
1487
|
}
|
|
1485
|
-
validatePreferencesInto(declared, `${path}.preferences`, into,
|
|
1488
|
+
validatePreferencesInto(declared, `${path}.preferences`, into, levels);
|
|
1486
1489
|
continue;
|
|
1487
1490
|
}
|
|
1488
1491
|
if (name === "team") {
|
|
@@ -1515,7 +1518,7 @@ export function validateAuthenticationState(state, path = "authentication", cont
|
|
|
1515
1518
|
// may carry an identity. Anything else is a state claiming knowledge it does not have.
|
|
1516
1519
|
if (state.status === "authenticated" || state.status === "refreshing") {
|
|
1517
1520
|
validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
|
|
1518
|
-
|
|
1521
|
+
validateUserCapabilitiesInto(state.capabilities, `${path}.capabilities`, into, context.levels);
|
|
1519
1522
|
}
|
|
1520
1523
|
else if (state.status === "expired") {
|
|
1521
1524
|
if (state.identity !== undefined)
|