@xema/omni-protocol 0.1.77 → 0.1.79
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 +8 -1
- package/dist/index.d.ts +49 -30
- package/dist/index.js +8 -3
- package/dist/testing.d.ts +6 -2
- package/dist/testing.js +235 -112
- package/dist/validation.js +192 -77
- package/guide.md +128 -86
- package/package.json +6 -2
package/dist/validation.js
CHANGED
|
@@ -47,6 +47,7 @@ const ACCEPTANCE_MODES = membersOf({
|
|
|
47
47
|
});
|
|
48
48
|
const TRANSPORT_STATUSES = membersOf({ connecting: true, active: true, error: true });
|
|
49
49
|
const AUTHENTICATION_METHODS = membersOf({ "browser-sso": true, credentials: true });
|
|
50
|
+
const AUTHENTICATION_BROWSERS = membersOf({ system: true, omni: true });
|
|
50
51
|
const AUTHENTICATION_STATUSES = membersOf({
|
|
51
52
|
"signed-out": true, authenticating: true, authenticated: true, refreshing: true, expired: true,
|
|
52
53
|
});
|
|
@@ -54,7 +55,7 @@ const BREAK_APPROVALS = membersOf({
|
|
|
54
55
|
"not-requested": true, "awaiting-decision": true, granted: true, "starting-after-task": true, "in-effect": true,
|
|
55
56
|
});
|
|
56
57
|
const TEAM_AVAILABILITIES = membersOf({
|
|
57
|
-
ready: true, "on-task": true, "on-break": true, "signed-out": true,
|
|
58
|
+
ready: true, "on-task": true, "on-break": true, reserved: true, "signed-out": true,
|
|
58
59
|
});
|
|
59
60
|
const HANDLING_STEPS = membersOf({
|
|
60
61
|
queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
|
|
@@ -82,7 +83,7 @@ const MEMBER_BREAKS = membersOf({
|
|
|
82
83
|
"awaiting-decision": true, granted: true, "starting-after-task": true,
|
|
83
84
|
});
|
|
84
85
|
const OFFERABLE_PHASES = membersOf({
|
|
85
|
-
pending: true,
|
|
86
|
+
pending: true,
|
|
86
87
|
});
|
|
87
88
|
const PREVIEW_DEADLINES = membersOf({ calls: true, expires: true });
|
|
88
89
|
const PHONES = membersOf({ softphone: true, deskPhone: true });
|
|
@@ -117,6 +118,16 @@ const isChannel = (value) => CHANNELS.includes(value);
|
|
|
117
118
|
// ---------------------------------------------------------------------------
|
|
118
119
|
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
119
120
|
const isFilled = (value) => typeof value === "string" && value.trim().length > 0;
|
|
121
|
+
/** Error text must not invoke conversion methods supplied by an untrusted object or array. */
|
|
122
|
+
function describeValue(value) {
|
|
123
|
+
if (value === null)
|
|
124
|
+
return "null";
|
|
125
|
+
if (typeof value === "object")
|
|
126
|
+
return Array.isArray(value) ? "an array" : "an object";
|
|
127
|
+
if (typeof value === "function")
|
|
128
|
+
return "a function";
|
|
129
|
+
return String(value);
|
|
130
|
+
}
|
|
120
131
|
/**
|
|
121
132
|
* An RFC-3339 timestamp carrying a zone.
|
|
122
133
|
*
|
|
@@ -124,9 +135,22 @@ const isFilled = (value) => typeof value === "string" && value.trim().length > 0
|
|
|
124
135
|
* happens to be in, so two hosts would read the same wire value as two different instants. The
|
|
125
136
|
* contract calls those invalid, and this is where that is enforced rather than assumed.
|
|
126
137
|
*/
|
|
127
|
-
const isIsoTimestamp = (value) =>
|
|
128
|
-
|
|
129
|
-
|
|
138
|
+
const isIsoTimestamp = (value) => {
|
|
139
|
+
if (typeof value !== "string")
|
|
140
|
+
return false;
|
|
141
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/.exec(value);
|
|
142
|
+
if (match === null)
|
|
143
|
+
return false;
|
|
144
|
+
const year = Number(match[1]);
|
|
145
|
+
const month = Number(match[2]);
|
|
146
|
+
const day = Number(match[3]);
|
|
147
|
+
// RFC 3339 sections 5.6–5.7: days depend on the month/year and hours are 00–23.
|
|
148
|
+
// Date.parse alone normalizes February 30 and 24:00 into different instants.
|
|
149
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
150
|
+
const daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
|
|
151
|
+
return daysInMonth !== undefined && day >= 1 && day <= daysInMonth
|
|
152
|
+
&& Number(match[4]) < 24 && !Number.isNaN(Date.parse(value));
|
|
153
|
+
};
|
|
130
154
|
/** A non-negative integer count of seconds. */
|
|
131
155
|
const isDurationSeconds = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
132
156
|
/** Opaque, non-empty, provider-issued. Never parsed and never compared across providers. */
|
|
@@ -168,7 +192,7 @@ class Collector {
|
|
|
168
192
|
return this.require(isIsoTimestamp(value), rule, path, "must be an RFC-3339 timestamp carrying a zone, such as 2026-08-21T09:00:00Z");
|
|
169
193
|
}
|
|
170
194
|
oneOf(value, allowed, rule, path) {
|
|
171
|
-
return this.require(typeof value === "string" && allowed.includes(value), rule, path, `must be one of ${allowed.join(", ")}; received ${
|
|
195
|
+
return this.require(typeof value === "string" && allowed.includes(value), rule, path, `must be one of ${allowed.join(", ")}; received ${describeValue(value)}`);
|
|
172
196
|
}
|
|
173
197
|
}
|
|
174
198
|
// ---------------------------------------------------------------------------
|
|
@@ -279,9 +303,8 @@ function validateIdleCapabilities(value, channel, path, into) {
|
|
|
279
303
|
}
|
|
280
304
|
else {
|
|
281
305
|
validateBrowserAccess(browser.access, `${path}.personalBrowser.access`, into);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
306
|
+
// Stated, never defaulted: an absent value read as all-navigation was a second spelling of one fact.
|
|
307
|
+
into.oneOf(browser.accessAppliesTo, ACCESS_APPLIES_TO, "manifest.personalBrowser.accessAppliesTo", `${path}.personalBrowser.accessAppliesTo`);
|
|
285
308
|
}
|
|
286
309
|
}
|
|
287
310
|
if (value.dial !== undefined) {
|
|
@@ -310,7 +333,7 @@ function validateDialOutcomes(manifest, path, into) {
|
|
|
310
333
|
into.require(!dials, "manifest.dialOutcomes.required", path, "a manifest that declares dial says how a dial ends: dialOutcomes with answered and at least one way of not reaching");
|
|
311
334
|
return;
|
|
312
335
|
}
|
|
313
|
-
if (!into.require(manifest.channel === "voice", "manifest.dialOutcomes.channel", path, `a ${
|
|
336
|
+
if (!into.require(manifest.channel === "voice", "manifest.dialOutcomes.channel", path, `a ${describeValue(manifest.channel)} provider dials nothing and declares no dial outcomes`))
|
|
314
337
|
return;
|
|
315
338
|
if (!Array.isArray(declared) || declared.length === 0) {
|
|
316
339
|
into.add("manifest.dialOutcomes.shape", path, "dialOutcomes is a non-empty array of the outcomes the platform distinguishes");
|
|
@@ -318,7 +341,7 @@ function validateDialOutcomes(manifest, path, into) {
|
|
|
318
341
|
}
|
|
319
342
|
declared.forEach((outcome, index) => {
|
|
320
343
|
if (into.oneOf(outcome, DIAL_OUTCOMES, "manifest.dialOutcome", `${path}[${index}]`) && declared.indexOf(outcome) !== index) {
|
|
321
|
-
into.add("manifest.dialOutcome.unique", `${path}[${index}]`, `duplicate dial outcome: ${
|
|
344
|
+
into.add("manifest.dialOutcome.unique", `${path}[${index}]`, `duplicate dial outcome: ${describeValue(outcome)}`);
|
|
322
345
|
}
|
|
323
346
|
});
|
|
324
347
|
into.require(declared.includes("answered"), "manifest.dialOutcomes.answered", path, "a dial that reached its destination is stated, never inferred: dialOutcomes includes answered");
|
|
@@ -336,7 +359,7 @@ function validateDialOutcomes(manifest, path, into) {
|
|
|
336
359
|
function validatePhones(manifest, path, into) {
|
|
337
360
|
const declared = manifest.phones;
|
|
338
361
|
if (manifest.channel !== "voice") {
|
|
339
|
-
into.require(declared === undefined, "manifest.phones.channel", path, `a ${
|
|
362
|
+
into.require(declared === undefined, "manifest.phones.channel", path, `a ${describeValue(manifest.channel)} provider has no call to hear and declares no phones`);
|
|
340
363
|
return;
|
|
341
364
|
}
|
|
342
365
|
if (!Array.isArray(declared) || declared.length === 0) {
|
|
@@ -345,7 +368,7 @@ function validatePhones(manifest, path, into) {
|
|
|
345
368
|
}
|
|
346
369
|
declared.forEach((phone, index) => {
|
|
347
370
|
if (into.oneOf(phone, PHONES, "manifest.phone", `${path}[${index}]`) && declared.indexOf(phone) !== index) {
|
|
348
|
-
into.add("manifest.phone.unique", `${path}[${index}]`, `duplicate phone: ${
|
|
371
|
+
into.add("manifest.phone.unique", `${path}[${index}]`, `duplicate phone: ${describeValue(phone)}`);
|
|
349
372
|
}
|
|
350
373
|
});
|
|
351
374
|
}
|
|
@@ -358,13 +381,13 @@ export function validatePhone(value, manifest, path = "context.phone") {
|
|
|
358
381
|
const channel = isPlainObject(manifest) ? manifest.channel : undefined;
|
|
359
382
|
const phones = isPlainObject(manifest) && Array.isArray(manifest.phones) ? manifest.phones : [];
|
|
360
383
|
if (channel !== "voice") {
|
|
361
|
-
into.require(value === undefined, "context.phone.unexpected", path, `a ${
|
|
384
|
+
into.require(value === undefined, "context.phone.unexpected", path, `a ${describeValue(channel)} login has no call to hear and chooses no phone`);
|
|
362
385
|
return into.violations;
|
|
363
386
|
}
|
|
364
387
|
if (!into.require(value !== undefined, "context.phone.required", path, "a voice login says how the agent hears the call: one of the manifest's phones"))
|
|
365
388
|
return into.violations;
|
|
366
389
|
if (into.oneOf(value, PHONES, "context.phone", path)) {
|
|
367
|
-
into.require(phones.includes(value), "context.phone.unsupported", path, `the host chose ${
|
|
390
|
+
into.require(phones.includes(value), "context.phone.unsupported", path, `the host chose ${describeValue(value)} and the manifest lists ${phones.length === 0 ? "no phones" : phones.map(describeValue).join(", ")}`);
|
|
368
391
|
}
|
|
369
392
|
return into.violations;
|
|
370
393
|
}
|
|
@@ -401,7 +424,7 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
401
424
|
else {
|
|
402
425
|
methods.forEach((method, index) => {
|
|
403
426
|
if (into.oneOf(method, AUTHENTICATION_METHODS, "manifest.authenticationMethod", `${path}.authenticationMethods[${index}]`) && methods.indexOf(method) !== index) {
|
|
404
|
-
into.add("manifest.authenticationMethod.unique", `${path}.authenticationMethods[${index}]`, `duplicate authentication method: ${
|
|
427
|
+
into.add("manifest.authenticationMethod.unique", `${path}.authenticationMethods[${index}]`, `duplicate authentication method: ${describeValue(method)}`);
|
|
405
428
|
}
|
|
406
429
|
});
|
|
407
430
|
}
|
|
@@ -568,8 +591,7 @@ function validateCustomCapabilities(value, path, into) {
|
|
|
568
591
|
into.oneOf(custom.ui.control, CUSTOM_UI_CONTROLS, "task.custom.ui.control", `${at}.ui.control`);
|
|
569
592
|
into.filled(custom.ui.label, "task.custom.ui.label", `${at}.ui.label`, "a custom control needs a label");
|
|
570
593
|
into.oneOf(custom.ui.placement, CUSTOM_UI_PLACEMENTS, "task.custom.ui.placement", `${at}.ui.placement`);
|
|
571
|
-
|
|
572
|
-
into.oneOf(custom.ui.render, CUSTOM_RENDERS, "task.custom.ui.render", `${at}.ui.render`);
|
|
594
|
+
into.oneOf(custom.ui.render, CUSTOM_RENDERS, "task.custom.ui.render", `${at}.ui.render`);
|
|
573
595
|
if (custom.prompt !== undefined) {
|
|
574
596
|
if (!isPlainObject(custom.prompt) || !Array.isArray(custom.prompt.fields)) {
|
|
575
597
|
into.add("task.custom.prompt.shape", `${at}.prompt`, "a prompt is an object with the fields the agent fills");
|
|
@@ -585,6 +607,10 @@ function validateCustomCapabilities(value, path, into) {
|
|
|
585
607
|
into.filled(field.name, "task.custom.prompt.field.name", `${where}.name`, "a prompt field needs a name");
|
|
586
608
|
into.filled(field.label, "task.custom.prompt.field.label", `${where}.label`, "a prompt field needs a label");
|
|
587
609
|
into.oneOf(field.type, CREDENTIAL_FIELD_TYPES, "task.custom.prompt.field.type", `${where}.type`);
|
|
610
|
+
if (field.required !== undefined)
|
|
611
|
+
into.require(typeof field.required === "boolean", "task.custom.prompt.field.required", `${where}.required`, "required must be a boolean when present");
|
|
612
|
+
if (field.autocomplete !== undefined)
|
|
613
|
+
into.require(typeof field.autocomplete === "string", "task.custom.prompt.field.autocomplete", `${where}.autocomplete`, "autocomplete must be a string when present");
|
|
588
614
|
});
|
|
589
615
|
}
|
|
590
616
|
}
|
|
@@ -651,7 +677,7 @@ function validateLockedByInto(value, rule, path, levels, into) {
|
|
|
651
677
|
if (!into.filled(value, rule, path, "lockedBy names the level that locked it"))
|
|
652
678
|
return;
|
|
653
679
|
into.require(value !== "person", `${rule}.person`, path, "a person never locks their own value");
|
|
654
|
-
into.require(levelIds(levels).includes(value), `${rule}.unknown`, path, `${
|
|
680
|
+
into.require(levelIds(levels).includes(value), `${rule}.unknown`, path, `${describeValue(value)} is not a level this manifest declares: in force are ${levelIds(levels).join(", ")}`);
|
|
655
681
|
}
|
|
656
682
|
/** `{ lockedBy, reason? }` standing in for a value: who locked it, and a reason if given. */
|
|
657
683
|
function validateLockedInto(value, rule, path, levels, into) {
|
|
@@ -661,8 +687,8 @@ function validateLockedInto(value, rule, path, levels, into) {
|
|
|
661
687
|
}
|
|
662
688
|
/** What every resolved value carries: who set it, and who locked it if anyone. */
|
|
663
689
|
function validateResolvedInto(value, rule, path, levels, into) {
|
|
664
|
-
if (into.filled(value.setBy, `${rule}.setBy`, `${path}.setBy`, "setBy names who stated the value: a level, or
|
|
665
|
-
into.require(value.setBy === "provider" || levelIds(levels).includes(value.setBy), `${rule}.setBy.unknown`, `${path}.setBy`, `${
|
|
690
|
+
if (into.filled(value.setBy, `${rule}.setBy`, `${path}.setBy`, "setBy names who stated the value: a level, or provider")) {
|
|
691
|
+
into.require(value.setBy === "provider" || levelIds(levels).includes(value.setBy), `${rule}.setBy.unknown`, `${path}.setBy`, `${describeValue(value.setBy)} is neither provider nor a level this manifest declares`);
|
|
666
692
|
}
|
|
667
693
|
if (value.lockedBy !== undefined)
|
|
668
694
|
validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, levels, into);
|
|
@@ -773,7 +799,7 @@ function validateTaskAttributes(value, path, into, levels) {
|
|
|
773
799
|
into.timestamp(attribute.at, "task.attribute.timestamp", `${at}.at`);
|
|
774
800
|
break;
|
|
775
801
|
default:
|
|
776
|
-
into.add("task.attribute.type", `${at}.type`, `unsupported attribute type: ${
|
|
802
|
+
into.add("task.attribute.type", `${at}.type`, `unsupported attribute type: ${describeValue(attribute.type)}`);
|
|
777
803
|
}
|
|
778
804
|
});
|
|
779
805
|
}
|
|
@@ -824,7 +850,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
|
|
|
824
850
|
// the task is paused, and a mute only while its media is up. An open entry after that is a leg
|
|
825
851
|
// nobody closed, which reads exactly like a leg running now.
|
|
826
852
|
if (entry.step === "held") {
|
|
827
|
-
into.require(task.phase === "paused", "task.handlingHistory.held.open", `${at}.seconds`, `a held entry without seconds is a hold still running, and the task is ${
|
|
853
|
+
into.require(task.phase === "paused", "task.handlingHistory.held.open", `${at}.seconds`, `a held entry without seconds is a hold still running, and the task is ${describeValue(task.phase)}: the hold has ended, and its duration is stated`);
|
|
828
854
|
}
|
|
829
855
|
if (entry.step === "muted") {
|
|
830
856
|
into.require(task.media !== "ended" && task.phase !== "completing", "task.handlingHistory.muted.open", `${at}.seconds`, "a muted entry without seconds is a mute still running, and the call is over: the provider closes every open leg at the instant the media ends, and the duration is stated");
|
|
@@ -851,7 +877,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
|
|
|
851
877
|
if (entry[field] === undefined)
|
|
852
878
|
continue;
|
|
853
879
|
if (into.filled(entry[field], `task.handlingHistory.${field}`, `${at}.${field}`, `${field} must not be empty when present`)) {
|
|
854
|
-
into.require(dialled, `task.handlingHistory.${field}.unexpected`, `${at}.${field}`, `${
|
|
880
|
+
into.require(dialled, `task.handlingHistory.${field}.unexpected`, `${at}.${field}`, `${describeValue(entry.step)} dialled nothing; ${field} belongs on transferred, conferenced, or unanswered`);
|
|
855
881
|
}
|
|
856
882
|
}
|
|
857
883
|
});
|
|
@@ -859,7 +885,11 @@ function validateHandlingHistory(value, path, into, task = {}) {
|
|
|
859
885
|
/** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
|
|
860
886
|
const TASK_MEDIA_STATES = membersOf({ started: true, ended: true });
|
|
861
887
|
/** Real-time media is a voice affair, and its state is one of two words. */
|
|
862
|
-
|
|
888
|
+
/** Whether a host dial is ringing the party: the party entry carries `stage: "ringing"` and the host's `dialId`. */
|
|
889
|
+
function partyRingingByHost(task) {
|
|
890
|
+
return Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && entry.role === "party" && entry.stage === "ringing" && typeof entry.dialId === "string");
|
|
891
|
+
}
|
|
892
|
+
function validateTaskMedia(task, value, channel, phase, path, into) {
|
|
863
893
|
if (value === undefined)
|
|
864
894
|
return;
|
|
865
895
|
if (!into.require(channel === "voice", "task.media.channel", path, `a ${channel} task carries no real-time media state`))
|
|
@@ -867,7 +897,9 @@ function validateTaskMedia(value, channel, phase, path, into) {
|
|
|
867
897
|
if (into.oneOf(value, TASK_MEDIA_STATES, "task.media", path)) {
|
|
868
898
|
// Nothing is acquired while pending, and a preview has placed no call: media names a task whose
|
|
869
899
|
// work has begun, on a snapshot as on the event, or a host would open the microphone on an offer.
|
|
870
|
-
|
|
900
|
+
// The one task not yet at work that has audio is one whose party the host is dialling: ring-back
|
|
901
|
+
// is audio, and every host-placed dial's media starts on dialling.
|
|
902
|
+
into.require(!WORK_NOT_BEGUN.includes(phase) || partyRingingByHost(task), "task.media.beforeWork", path, `a ${describeValue(phase)} task has no media: audio arrives once its work has begun, or once the host is dialling its party`);
|
|
871
903
|
}
|
|
872
904
|
}
|
|
873
905
|
const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
|
|
@@ -1075,7 +1107,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1075
1107
|
}
|
|
1076
1108
|
// The channel is fixed per provider by its manifest, so a task claiming another one is a
|
|
1077
1109
|
// task Omni would render with the wrong controls.
|
|
1078
|
-
into.require(task.channel === context.channel, "task.channel", `${path}.channel`, `a ${context.channel} provider may not publish a ${
|
|
1110
|
+
into.require(task.channel === context.channel, "task.channel", `${path}.channel`, `a ${context.channel} provider may not publish a ${describeValue(task.channel)} task`);
|
|
1079
1111
|
if (task.reference !== undefined) {
|
|
1080
1112
|
into.filled(task.reference, "task.reference", `${path}.reference`, "a reference must not be empty when present");
|
|
1081
1113
|
}
|
|
@@ -1100,7 +1132,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1100
1132
|
if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
|
|
1101
1133
|
into.add("task.onCall.ended", `${path}.onCall`, `the call has ended (${task.phase === "completing" ? "the task is completing" : "its media ended"}) and onCall still names people on it: the room is who is on the call now, and now nobody is`);
|
|
1102
1134
|
}
|
|
1103
|
-
validateTaskMedia(task.media, context.channel, task.phase, `${path}.media`, into);
|
|
1135
|
+
validateTaskMedia(task, task.media, context.channel, task.phase, `${path}.media`, into);
|
|
1104
1136
|
validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
|
|
1105
1137
|
validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
|
|
1106
1138
|
validateMonitoring(task.monitoring, context.channel, `${path}.monitoring`, into);
|
|
@@ -1217,7 +1249,9 @@ function validateBreakState(value, path, into) {
|
|
|
1217
1249
|
if (value.imposed !== undefined) {
|
|
1218
1250
|
validateImposedBreak(value.imposed, `${path}.imposed`, into);
|
|
1219
1251
|
// An imposed break is a break somebody placed; beside `not-requested` there is no break.
|
|
1220
|
-
|
|
1252
|
+
// An imposed break is a break in progress or about to be: it travels with in-effect or
|
|
1253
|
+
// starting-after-task and nothing else. Beside granted, the host would commit a break nobody asked for.
|
|
1254
|
+
into.require(value.approval === "in-effect" || value.approval === "starting-after-task", "break.imposed.approval", `${path}.imposed`, `an imposed break is in effect or starting after the task; ${describeValue(value.approval)} says the agent asked, or that there is none`);
|
|
1221
1255
|
}
|
|
1222
1256
|
if (value.reasons === undefined)
|
|
1223
1257
|
return;
|
|
@@ -1252,6 +1286,13 @@ function validateBreakState(value, path, into) {
|
|
|
1252
1286
|
if (typeof value.activeReasonId === "string" && value.activeReasonId.length > 0) {
|
|
1253
1287
|
into.require(seen.has(value.activeReasonId), "break.activeReasonId.known", `${path}.activeReasonId`, `activeReasonId names a reason the provider did not publish: ${value.activeReasonId}`);
|
|
1254
1288
|
}
|
|
1289
|
+
// A break in effect, or about to be, on a provider that publishes reasons is on one of them: an
|
|
1290
|
+
// imposed one included, since the lead's place named it. A break of no kind is a break whose
|
|
1291
|
+
// rules -- who may monitor through it, whether it counts -- nobody can apply.
|
|
1292
|
+
ruleEvaluated("break.activeReasonId.required");
|
|
1293
|
+
if (seen.size > 0 && (value.approval === "in-effect" || value.approval === "starting-after-task")) {
|
|
1294
|
+
into.require(typeof value.activeReasonId === "string" && value.activeReasonId.length > 0, "break.activeReasonId.required", `${path}.activeReasonId`, `a break ${describeValue(value.approval)} on a provider that publishes reasons names the one it is on`);
|
|
1295
|
+
}
|
|
1255
1296
|
}
|
|
1256
1297
|
export function validateTeamRoster(roster, path = "team", context = {}) {
|
|
1257
1298
|
const into = new Collector();
|
|
@@ -1357,10 +1398,10 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1357
1398
|
}
|
|
1358
1399
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
1359
1400
|
const levels = context.levels ?? manifestLevels(manifest);
|
|
1360
|
-
into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.
|
|
1401
|
+
into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.transport`);
|
|
1361
1402
|
if (into.filled(snapshot.loginId, "snapshot.loginId", `${path}.loginId`, "a snapshot needs the login id it belongs to")
|
|
1362
1403
|
&& context.loginId !== undefined) {
|
|
1363
|
-
into.require(snapshot.loginId === context.loginId, "snapshot.loginId.mismatch", `${path}.loginId`, `a snapshot for session ${
|
|
1404
|
+
into.require(snapshot.loginId === context.loginId, "snapshot.loginId.mismatch", `${path}.loginId`, `a snapshot for session ${describeValue(snapshot.loginId)} on a login whose session is ${context.loginId}`);
|
|
1364
1405
|
}
|
|
1365
1406
|
validateBreakState(snapshot.break, `${path}.break`, into);
|
|
1366
1407
|
// The count is the provider's confirmation of how much work it answered with. Stated, never
|
|
@@ -1408,6 +1449,14 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1408
1449
|
// describes a state the agent cannot be in, whichever half is stale. The one exception is a
|
|
1409
1450
|
// lead listening to a call during a break that is work of another sort -- coaching,
|
|
1410
1451
|
// administrative, training -- and on no other.
|
|
1452
|
+
// The converse: a committed break with nothing outstanding has begun. starting-after-task beside
|
|
1453
|
+
// no task is a break waiting on work that does not exist, and an empty list reading as "still
|
|
1454
|
+
// finishing" is the plausible nought.
|
|
1455
|
+
ruleEvaluated("break.starting-after-task.tasks");
|
|
1456
|
+
if (isPlainObject(snapshot.break) && snapshot.break.approval === "starting-after-task"
|
|
1457
|
+
&& Array.isArray(snapshot.tasks) && snapshot.tasks.length === 0) {
|
|
1458
|
+
into.add("break.starting-after-task.tasks", `${path}.break.approval`, "a break starting after the task waits on a task, and the snapshot carries none: with nothing outstanding the break is in-effect");
|
|
1459
|
+
}
|
|
1411
1460
|
if (isPlainObject(snapshot.break) && snapshot.break.approval === "in-effect"
|
|
1412
1461
|
&& Array.isArray(snapshot.tasks) && snapshot.tasks.length > 0) {
|
|
1413
1462
|
const listening = snapshot.tasks.every((task) => isPlainObject(task) && task.monitoring !== undefined);
|
|
@@ -1419,7 +1468,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1419
1468
|
const reasons = Array.isArray(state.reasons) ? state.reasons : [];
|
|
1420
1469
|
const active = reasons.find((reason) => isPlainObject(reason) && reason.id === state.activeReasonId);
|
|
1421
1470
|
const kind = isPlainObject(active) ? active.kind : undefined;
|
|
1422
|
-
into.require(typeof kind === "string" && MONITORING_BREAK_KINDS.includes(kind), "snapshot.monitoring.break", `${path}.tasks`, `a lead listens during a ${MONITORING_BREAK_KINDS.join(", ")} break and no other; the active reason is ${kind === undefined ? "unstated" :
|
|
1471
|
+
into.require(typeof kind === "string" && MONITORING_BREAK_KINDS.includes(kind), "snapshot.monitoring.break", `${path}.tasks`, `a lead listens during a ${MONITORING_BREAK_KINDS.join(", ")} break and no other; the active reason is ${kind === undefined ? "unstated" : describeValue(kind)}`);
|
|
1423
1472
|
}
|
|
1424
1473
|
}
|
|
1425
1474
|
// Presence is the permission, and it cuts both ways: data a provider never declared a
|
|
@@ -1487,6 +1536,10 @@ function validateTaskOutcome(value, path, into) {
|
|
|
1487
1536
|
into.filled(value.destinationId, "event.taskEnded.outcome.transferred", `${path}.destinationId`, "a destinationId must not be empty when present");
|
|
1488
1537
|
}
|
|
1489
1538
|
break;
|
|
1539
|
+
case "taken-over":
|
|
1540
|
+
// A lead is not a directory item: the ending names the lead, and the host resolves the name.
|
|
1541
|
+
into.require(isUserId(value.leadId), "event.taskEnded.outcome.takenOver", `${path}.leadId`, "a take-over names the lead who took the call, by user id");
|
|
1542
|
+
break;
|
|
1490
1543
|
case "cancelled":
|
|
1491
1544
|
// One word covered the agent declining, the provider withdrawing and the party abandoning the
|
|
1492
1545
|
// ring; a record that cannot tell them apart is not a record. by says who, as completed does.
|
|
@@ -1510,7 +1563,7 @@ function validateTaskOutcome(value, path, into) {
|
|
|
1510
1563
|
}
|
|
1511
1564
|
break;
|
|
1512
1565
|
default:
|
|
1513
|
-
into.add("event.taskEnded.outcome.type", `${path}.type`, `unsupported outcome: ${
|
|
1566
|
+
into.add("event.taskEnded.outcome.type", `${path}.type`, `unsupported outcome: ${describeValue(value.type)}`);
|
|
1514
1567
|
}
|
|
1515
1568
|
}
|
|
1516
1569
|
function validateQueueSummary(value, path, into) {
|
|
@@ -1558,7 +1611,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1558
1611
|
into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
|
|
1559
1612
|
if (into.filled(envelope.loginId, "event.loginId", `${path}.loginId`, "an event needs the login id it belongs to")
|
|
1560
1613
|
&& context.loginId !== undefined) {
|
|
1561
|
-
into.require(envelope.loginId === context.loginId, "event.loginId.mismatch", `${path}.loginId`, `an event for session ${
|
|
1614
|
+
into.require(envelope.loginId === context.loginId, "event.loginId.mismatch", `${path}.loginId`, `an event for session ${describeValue(envelope.loginId)} on a login whose session is ${context.loginId}`);
|
|
1562
1615
|
}
|
|
1563
1616
|
const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
|
|
1564
1617
|
into.timestamp(envelope.occurredAt, "event.occurredAt", `${path}.occurredAt`);
|
|
@@ -1604,10 +1657,12 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1604
1657
|
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1605
1658
|
break;
|
|
1606
1659
|
case "task-media-started":
|
|
1660
|
+
into.require(channel === "voice", "event.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
|
|
1607
1661
|
into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
|
|
1608
1662
|
into.require(isAllocationId(event.allocationId), "event.taskMediaStarted.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
|
|
1609
1663
|
break;
|
|
1610
1664
|
case "task-media-ended":
|
|
1665
|
+
into.require(channel === "voice", "event.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
|
|
1611
1666
|
into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
1612
1667
|
into.require(isAllocationId(event.allocationId), "event.taskMediaEnded.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
|
|
1613
1668
|
break;
|
|
@@ -1621,7 +1676,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1621
1676
|
// Only a distinction the manifest declared: an undeclared word is a guess dressed as a code.
|
|
1622
1677
|
const declared = isPlainObject(manifest) && Array.isArray(manifest.dialOutcomes) ? manifest.dialOutcomes : undefined;
|
|
1623
1678
|
if (into.oneOf(event.outcome, DIAL_OUTCOMES, "event.dialOutcome.outcome", `${at}.outcome`)) {
|
|
1624
|
-
into.require(declared !== undefined && declared.includes(event.outcome), "event.dialOutcome.undeclared", `${at}.outcome`, `the manifest does not declare ${
|
|
1679
|
+
into.require(declared !== undefined && declared.includes(event.outcome), "event.dialOutcome.undeclared", `${at}.outcome`, `the manifest does not declare ${describeValue(event.outcome)} among its dialOutcomes`);
|
|
1625
1680
|
}
|
|
1626
1681
|
if (event.taskId !== undefined)
|
|
1627
1682
|
into.require(isTaskId(event.taskId), "event.dialOutcome.taskId", `${at}.taskId`, "taskId must be a task id when present");
|
|
@@ -1681,7 +1736,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1681
1736
|
}
|
|
1682
1737
|
break;
|
|
1683
1738
|
default:
|
|
1684
|
-
into.add("event.type", `${at}.type`, `unsupported event type: ${
|
|
1739
|
+
into.add("event.type", `${at}.type`, `unsupported event type: ${describeValue(event.type)}`);
|
|
1685
1740
|
}
|
|
1686
1741
|
return into.violations;
|
|
1687
1742
|
}
|
|
@@ -1917,7 +1972,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1917
1972
|
into.require(input.mutedBy === undefined, "host.audio.input.mutedBy.unexpected", `${at}.mutedBy`, "an unavailable input was not silenced; it is absent");
|
|
1918
1973
|
}
|
|
1919
1974
|
else {
|
|
1920
|
-
into.add("host.audio.input.status", `${at}.status`, `an input is available or unavailable, not ${
|
|
1975
|
+
into.add("host.audio.input.status", `${at}.status`, `an input is available or unavailable, not ${describeValue(input.status)}`);
|
|
1921
1976
|
}
|
|
1922
1977
|
const output = report.audio.output;
|
|
1923
1978
|
const out = `${path}.audio.output`;
|
|
@@ -1940,7 +1995,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1940
1995
|
into.require(output.mutedBy === undefined, "host.audio.output.mutedBy.unexpected", `${out}.mutedBy`, "an unavailable output was not silenced; it is absent");
|
|
1941
1996
|
}
|
|
1942
1997
|
else {
|
|
1943
|
-
into.add("host.audio.output.status", `${out}.status`, `an output is available or unavailable, not ${
|
|
1998
|
+
into.add("host.audio.output.status", `${out}.status`, `an output is available or unavailable, not ${describeValue(output.status)}`);
|
|
1944
1999
|
}
|
|
1945
2000
|
return into.violations;
|
|
1946
2001
|
}
|
|
@@ -1971,25 +2026,8 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1971
2026
|
: [...new Set([...TASK_COMMAND_NAMES.voice, ...TASK_COMMAND_NAMES.chat, ...TASK_COMMAND_NAMES.email])];
|
|
1972
2027
|
if (type === "custom") {
|
|
1973
2028
|
into.filled(command.name, "command.custom.name", `${path}.name`, "a custom command names the control it presses");
|
|
1974
|
-
if (task === undefined)
|
|
1975
|
-
return into.violations;
|
|
1976
|
-
// Held to the task: the control it presses is one the task published, and what the control asked for travels with it.
|
|
1977
|
-
const custom = isPlainObject(task) && isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
|
|
1978
|
-
const control = Array.isArray(custom) ? custom.filter(isPlainObject).find(entry => entry.id === command.name) : undefined;
|
|
1979
|
-
if (control === undefined) {
|
|
1980
|
-
into.add("command.capability.custom", path, `custom ${String(command.name)} presses a control the task never published`);
|
|
1981
|
-
return into.violations;
|
|
1982
|
-
}
|
|
1983
|
-
if (isPlainObject(control.prompt) && Array.isArray(control.prompt.fields)) {
|
|
1984
|
-
for (const field of control.prompt.fields.filter(isPlainObject)) {
|
|
1985
|
-
if (typeof field.name === "string") {
|
|
1986
|
-
into.require(typeof command[field.name] === "string" && command[field.name].length > 0, "command.custom.prompt", `${path}.${field.name}`, `the control asked for ${field.name} and the command does not carry it`);
|
|
1987
|
-
}
|
|
1988
|
-
}
|
|
1989
|
-
}
|
|
1990
|
-
return into.violations;
|
|
1991
2029
|
}
|
|
1992
|
-
if (!into.require(typeof type === "string" && names.includes(type), "command.type", `${path}.type`, channel === undefined ? `unsupported command: ${
|
|
2030
|
+
if (type !== "custom" && !into.require(typeof type === "string" && names.includes(type), "command.type", `${path}.type`, channel === undefined ? `unsupported command: ${describeValue(type)}` : `a ${channel} task has no ${describeValue(type)} command`)) {
|
|
1993
2031
|
return into.violations;
|
|
1994
2032
|
}
|
|
1995
2033
|
const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
|
|
@@ -2007,7 +2045,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2007
2045
|
into.filled(command.destinationId, "command.transfer.destinationId", `${path}.destinationId`, "a transfer names the directory item it goes to");
|
|
2008
2046
|
}
|
|
2009
2047
|
else {
|
|
2010
|
-
into.require(command.dialId === undefined && command.destinationId === undefined, "command.transfer.unexpected", path, `${
|
|
2048
|
+
into.require(command.dialId === undefined && command.destinationId === undefined, "command.transfer.unexpected", path, `${describeValue(command.action)} names no destination: there is exactly one it could mean`);
|
|
2011
2049
|
}
|
|
2012
2050
|
}
|
|
2013
2051
|
break;
|
|
@@ -2041,7 +2079,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2041
2079
|
default:
|
|
2042
2080
|
break;
|
|
2043
2081
|
}
|
|
2044
|
-
if (
|
|
2082
|
+
if (task === undefined)
|
|
2045
2083
|
return into.violations;
|
|
2046
2084
|
// The task the command is held to has to be one the wire published. A host's own mapping of it,
|
|
2047
2085
|
// handed in for want of the original, is not, and checking a command against it would be
|
|
@@ -2049,25 +2087,45 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2049
2087
|
// only to a task that stands.
|
|
2050
2088
|
const published = new Collector();
|
|
2051
2089
|
validateTaskInto(task, { channel: channel ?? "voice" }, `${path}.task`, published);
|
|
2052
|
-
if (published.violations.length > 0) {
|
|
2090
|
+
if (published.violations.length > 0 || !isPlainObject(task)) {
|
|
2053
2091
|
into.add("command.task", `${path}.task`, `the task the command is held to is not one the wire published (${published.violations.map(v => v.rule).join(", ")}): pass the task as the provider sent it, or validate the command's shape alone`);
|
|
2054
2092
|
return into.violations;
|
|
2055
2093
|
}
|
|
2094
|
+
if (type === "custom") {
|
|
2095
|
+
// Held to the task: the control it presses is one the task published, and what the control asked for travels with it.
|
|
2096
|
+
const custom = isPlainObject(task) && isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
|
|
2097
|
+
const control = Array.isArray(custom) ? custom.filter(isPlainObject).find(entry => entry.id === command.name) : undefined;
|
|
2098
|
+
if (control === undefined) {
|
|
2099
|
+
into.add("command.capability.custom", path, `custom ${describeValue(command.name)} presses a control the task never published`);
|
|
2100
|
+
return into.violations;
|
|
2101
|
+
}
|
|
2102
|
+
if (isPlainObject(control.ui) && control.ui.control === "toggle") {
|
|
2103
|
+
into.require(typeof command.on === "boolean", "command.custom.on", `${path}.on`, "a custom toggle carries the state it wants as a boolean");
|
|
2104
|
+
}
|
|
2105
|
+
if (isPlainObject(control.prompt) && Array.isArray(control.prompt.fields)) {
|
|
2106
|
+
for (const field of control.prompt.fields.filter(isPlainObject)) {
|
|
2107
|
+
if (typeof field.name === "string" && (field.required === true || command[field.name] !== undefined)) {
|
|
2108
|
+
into.require(typeof command[field.name] === "string" && (field.required !== true || command[field.name].length > 0), "command.custom.prompt", `${path}.${field.name}`, `${field.name} must be a string when supplied, and a required field must have a value`);
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
return into.violations;
|
|
2113
|
+
}
|
|
2056
2114
|
// What the task has to offer or be in for the command to be issuable.
|
|
2057
2115
|
const capabilities = isPlainObject(task.capabilities) ? task.capabilities : {};
|
|
2058
2116
|
const offered = (name) => {
|
|
2059
2117
|
const declared = capabilities[name];
|
|
2060
2118
|
if (declared === undefined) {
|
|
2061
|
-
into.add(`command.capability.${name}`, path, `${
|
|
2119
|
+
into.add(`command.capability.${name}`, path, `${describeValue(type)} needs the ${name} capability, and the task does not offer it`);
|
|
2062
2120
|
return false;
|
|
2063
2121
|
}
|
|
2064
2122
|
if (isLocked(declared)) {
|
|
2065
|
-
into.add("command.capability.locked", path, `${name} stands locked by ${
|
|
2123
|
+
into.add("command.capability.locked", path, `${name} stands locked by ${describeValue(declared.lockedBy)}: the control is present without permission`);
|
|
2066
2124
|
return false;
|
|
2067
2125
|
}
|
|
2068
2126
|
return true;
|
|
2069
2127
|
};
|
|
2070
|
-
const inPhase = (rule, ...phases) => into.require(phases.includes(
|
|
2128
|
+
const inPhase = (rule, ...phases) => into.require(typeof task.phase === "string" && phases.includes(task.phase), rule, path, `${describeValue(type)} belongs to ${phases.join(" or ")}, and the task is ${describeValue(task.phase)}`);
|
|
2071
2129
|
// A control on the contact acts on a contact being handled. Before in-progress there is nothing
|
|
2072
2130
|
// to act on yet, and in completing the handling has ended: a call with nobody on it, a
|
|
2073
2131
|
// conversation closed. What a capability offers, the phase decides whether there is anything to use it on.
|
|
@@ -2076,7 +2134,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2076
2134
|
const listed = (name) => {
|
|
2077
2135
|
const declared = capabilities[name];
|
|
2078
2136
|
const directory = isPlainObject(declared) && Array.isArray(declared.destinations) ? declared.destinations.filter(isPlainObject) : [];
|
|
2079
|
-
into.require(directory.some(item => item.id === command.destinationId), "command.destination.unknown", `${path}.destinationId`, `${
|
|
2137
|
+
into.require(directory.some(item => item.id === command.destinationId), "command.destination.unknown", `${path}.destinationId`, `${describeValue(command.destinationId)} is not a destination the task's ${name} directory offered`);
|
|
2080
2138
|
};
|
|
2081
2139
|
const onCall = Array.isArray(task.onCall) ? task.onCall.filter(isPlainObject) : [];
|
|
2082
2140
|
switch (type) {
|
|
@@ -2119,7 +2177,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2119
2177
|
listed("warmTransfer");
|
|
2120
2178
|
}
|
|
2121
2179
|
else if (command.action === "complete" || command.action === "cancel") {
|
|
2122
|
-
into.require(onCall.some(entry => entry.role === "consulted"), "command.transfer.consulted", path, `${
|
|
2180
|
+
into.require(onCall.some(entry => entry.role === "consulted"), "command.transfer.consulted", path, `${describeValue(command.action)} needs a consulted entry on onCall: without one there is nothing to complete or cancel`);
|
|
2123
2181
|
}
|
|
2124
2182
|
break;
|
|
2125
2183
|
case "lead-assist":
|
|
@@ -2131,7 +2189,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2131
2189
|
}
|
|
2132
2190
|
}
|
|
2133
2191
|
else {
|
|
2134
|
-
into.require(task.assisting !== undefined, "command.leadAssist.assisting", path, `${
|
|
2192
|
+
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`);
|
|
2135
2193
|
}
|
|
2136
2194
|
break;
|
|
2137
2195
|
case "conference": {
|
|
@@ -2145,7 +2203,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2145
2203
|
into.require(others.some(entry => entry.role !== "party"), "command.conference.remove.alone", path, "removing the party would leave the agent alone: that is end-call");
|
|
2146
2204
|
}
|
|
2147
2205
|
else if (isFilled(command.destinationId)) {
|
|
2148
|
-
into.require(onCall.some(entry => entry.role === "conferenced" && entry.destinationId === command.destinationId), "command.conference.remove.unknown", path, `no conferenced entry on onCall has destinationId ${
|
|
2206
|
+
into.require(onCall.some(entry => entry.role === "conferenced" && entry.destinationId === command.destinationId), "command.conference.remove.unknown", path, `no conferenced entry on onCall has destinationId ${describeValue(command.destinationId)}`);
|
|
2149
2207
|
into.require(others.length > 1, "command.conference.remove.alone", path, "removing the last other person would leave the agent alone: that is end-call");
|
|
2150
2208
|
}
|
|
2151
2209
|
}
|
|
@@ -2160,9 +2218,12 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2160
2218
|
into.require(command.disposition === undefined, "command.complete.disposition.unexpected", `${path}.disposition`, "the task declares no dispositions; complete travels with no code");
|
|
2161
2219
|
into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task declares no dispositions; complete travels with no notes");
|
|
2162
2220
|
}
|
|
2221
|
+
else if (dispositions === true) {
|
|
2222
|
+
into.require(command.disposition === undefined, "command.complete.disposition.unexpected", `${path}.disposition`, "a bare dispositions control publishes no code, so complete carries none");
|
|
2223
|
+
}
|
|
2163
2224
|
else if (isPlainObject(dispositions)) {
|
|
2164
2225
|
if (Array.isArray(dispositions.codes) && command.disposition !== undefined) {
|
|
2165
|
-
into.require(dispositions.codes.some(code => isPlainObject(code) && code.id === command.disposition), "command.complete.disposition.unknown", `${path}.disposition`, `${
|
|
2226
|
+
into.require(dispositions.codes.some(code => isPlainObject(code) && code.id === command.disposition), "command.complete.disposition.unknown", `${path}.disposition`, `${describeValue(command.disposition)} is not a code the task published`);
|
|
2166
2227
|
}
|
|
2167
2228
|
if (dispositions.required === true) {
|
|
2168
2229
|
into.require(command.disposition !== undefined, "command.complete.disposition.required", `${path}.disposition`, "the task requires a disposition and complete carries none");
|
|
@@ -2186,7 +2247,8 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2186
2247
|
const RESULT_STATUSES = {
|
|
2187
2248
|
execute: { success: "applied", failure: "failed" },
|
|
2188
2249
|
dial: { success: "dialling", failure: "failed" },
|
|
2189
|
-
|
|
2250
|
+
// A statement, not a request: taken, never refused.
|
|
2251
|
+
setCapacity: { success: "applied", failure: undefined },
|
|
2190
2252
|
requestBreak: { success: "requested", failure: "failed" },
|
|
2191
2253
|
commitBreak: { success: "committed", failure: "failed" },
|
|
2192
2254
|
cancelBreak: { success: "cancelled", failure: "failed" },
|
|
@@ -2215,21 +2277,34 @@ export function validateResult(result, method, path = "result", dialId) {
|
|
|
2215
2277
|
into.add("result.shape", path, `${method} must answer an object`);
|
|
2216
2278
|
return into.violations;
|
|
2217
2279
|
}
|
|
2280
|
+
if (method === "openMedia" && result.status !== "opened") {
|
|
2281
|
+
into.require(result.session === undefined, "result.session.unexpected", `${path}.session`, "only opened carries a media session");
|
|
2282
|
+
}
|
|
2218
2283
|
if (result.status === statuses.success) {
|
|
2219
2284
|
into.require(result.failure === undefined, "result.failure.unexpected", `${path}.failure`, `${statuses.success} carries no failure`);
|
|
2220
2285
|
if (method === "openMedia") {
|
|
2221
|
-
|
|
2286
|
+
if (isPlainObject(result.session)) {
|
|
2287
|
+
// Streams may come from another realm, and Node conformance runs have no MediaStream
|
|
2288
|
+
// constructor. Check the boundary shape without claiming to verify live audio.
|
|
2289
|
+
into.require(isPlainObject(result.session.remoteAudio), "result.session.remoteAudio", `${path}.session.remoteAudio`, "an opened session carries its remote audio stream");
|
|
2290
|
+
for (const method of ["setMuted", "close"]) {
|
|
2291
|
+
into.require(typeof result.session[method] === "function", `result.session.${method}`, `${path}.session.${method}`, `a media session implements ${method}`);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
else {
|
|
2295
|
+
into.add("result.session", `${path}.session`, "opened carries the media session");
|
|
2296
|
+
}
|
|
2222
2297
|
}
|
|
2223
2298
|
if (dials) {
|
|
2224
2299
|
if (into.filled(result.dialId, "result.dialId", `${path}.dialId`, "dialling restates the dialId the host sent") && dialId !== undefined) {
|
|
2225
|
-
into.require(result.dialId === dialId, "result.dialId.mismatch", `${path}.dialId`, `the host dialled ${dialId} and the provider answered for ${
|
|
2300
|
+
into.require(result.dialId === dialId, "result.dialId.mismatch", `${path}.dialId`, `the host dialled ${dialId} and the provider answered for ${describeValue(result.dialId)}`);
|
|
2226
2301
|
}
|
|
2227
2302
|
}
|
|
2228
2303
|
else {
|
|
2229
2304
|
into.require(result.dialId === undefined, "result.dialId.unexpected", `${path}.dialId`, `${statuses.success} dialled nothing and names no dial`);
|
|
2230
2305
|
}
|
|
2231
2306
|
}
|
|
2232
|
-
else if (result.status === statuses.failure) {
|
|
2307
|
+
else if (statuses.failure !== undefined && result.status === statuses.failure) {
|
|
2233
2308
|
if (result.failure === undefined) {
|
|
2234
2309
|
into.add("result.failure.required", `${path}.failure`, `${statuses.failure} carries the failure that says why`);
|
|
2235
2310
|
}
|
|
@@ -2238,7 +2313,7 @@ export function validateResult(result, method, path = "result", dialId) {
|
|
|
2238
2313
|
}
|
|
2239
2314
|
}
|
|
2240
2315
|
else {
|
|
2241
|
-
into.add("result.status", `${path}.status`, `${method} answers ${statuses.success} or ${statuses.failure}, not ${
|
|
2316
|
+
into.add("result.status", `${path}.status`, `${method} answers ${statuses.success} or ${statuses.failure}, not ${describeValue(result.status)}`);
|
|
2242
2317
|
}
|
|
2243
2318
|
return into.violations;
|
|
2244
2319
|
}
|
|
@@ -2351,7 +2426,7 @@ function validateMonitorControl(value, path, into) {
|
|
|
2351
2426
|
}
|
|
2352
2427
|
value.forEach((mode, index) => {
|
|
2353
2428
|
if (into.oneOf(mode, MONITOR_MODES, "authentication.capability.team.monitorControl.mode", `${path}[${index}]`) && value.indexOf(mode) !== index) {
|
|
2354
|
-
into.add("authentication.capability.team.monitorControl.unique", `${path}[${index}]`, `duplicate monitor mode: ${
|
|
2429
|
+
into.add("authentication.capability.team.monitorControl.unique", `${path}[${index}]`, `duplicate monitor mode: ${describeValue(mode)}`);
|
|
2355
2430
|
}
|
|
2356
2431
|
});
|
|
2357
2432
|
into.require(value.includes("monitor"), "authentication.capability.team.monitorControl.monitor", path, "whisper and barge begin from a monitor: a lead who may listen in any mode may monitor");
|
|
@@ -2368,7 +2443,7 @@ function validateAuthenticationFailureInto(value, path, into) {
|
|
|
2368
2443
|
return;
|
|
2369
2444
|
}
|
|
2370
2445
|
if (into.filled(value.code, "authentication.failure.code", `${path}.code`, "a failure needs a code") && value.code.startsWith("omni.")) {
|
|
2371
|
-
into.require(OMNI_FAILURE_CODES.includes(value.code), "failure.code.unknown", `${path}.code`, `${
|
|
2446
|
+
into.require(OMNI_FAILURE_CODES.includes(value.code), "failure.code.unknown", `${path}.code`, `${describeValue(value.code)} is not a failure code this contract defines`);
|
|
2372
2447
|
}
|
|
2373
2448
|
into.filled(value.message, "authentication.failure.message", `${path}.message`, "a failure needs a message");
|
|
2374
2449
|
into.require(typeof value.retryable === "boolean", "authentication.failure.retryable", `${path}.retryable`, "a failure must say whether it is retryable");
|
|
@@ -2376,7 +2451,7 @@ function validateAuthenticationFailureInto(value, path, into) {
|
|
|
2376
2451
|
into.require(value.retryable === false, "authentication.failure.phone.retryable", `${path}.retryable`, "a phone the platform does not permit for this agent is configuration: trying again does not change it, so the refusal is not retryable");
|
|
2377
2452
|
}
|
|
2378
2453
|
if (value.retryAfterMs !== undefined) {
|
|
2379
|
-
into.require(typeof value.retryAfterMs === "number" && value.retryAfterMs >= 0, "authentication.failure.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
|
|
2454
|
+
into.require(typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) && value.retryAfterMs >= 0, "authentication.failure.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
|
|
2380
2455
|
}
|
|
2381
2456
|
if (value.field !== undefined)
|
|
2382
2457
|
into.filled(value.field, "authentication.failure.field", `${path}.field`, "field names a declared credentials field when present");
|
|
@@ -2386,6 +2461,42 @@ export function validateAuthenticationFailure(failure, path = "failure") {
|
|
|
2386
2461
|
validateAuthenticationFailureInto(failure, path, into);
|
|
2387
2462
|
return into.violations;
|
|
2388
2463
|
}
|
|
2464
|
+
function validateAuthenticationChallenge(value, path, into) {
|
|
2465
|
+
if (!isPlainObject(value)) {
|
|
2466
|
+
into.add("authentication.result.challenge", path, "interaction-required carries the challenge to put to the person");
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
into.filled(value.flowId, "authentication.challenge.flowId", `${path}.flowId`, "a challenge names the flow to complete or cancel");
|
|
2470
|
+
if (!into.oneOf(value.method, AUTHENTICATION_METHODS, "authentication.challenge.method", `${path}.method`))
|
|
2471
|
+
return;
|
|
2472
|
+
if (value.method === "browser-sso") {
|
|
2473
|
+
into.filled(value.authorizationUrl, "authentication.challenge.authorizationUrl", `${path}.authorizationUrl`, "browser SSO carries the authorization URL to open");
|
|
2474
|
+
into.oneOf(value.browser, AUTHENTICATION_BROWSERS, "authentication.challenge.browser", `${path}.browser`);
|
|
2475
|
+
}
|
|
2476
|
+
else if (!Array.isArray(value.fields)) {
|
|
2477
|
+
into.add("authentication.challenge.fields", `${path}.fields`, "credentials carries the fields to render");
|
|
2478
|
+
}
|
|
2479
|
+
else {
|
|
2480
|
+
const seen = new Set();
|
|
2481
|
+
value.fields.forEach((field, index) => {
|
|
2482
|
+
const at = `${path}.fields[${index}]`;
|
|
2483
|
+
if (!isPlainObject(field)) {
|
|
2484
|
+
into.add("authentication.challenge.field.shape", at, "a credentials field must be an object");
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
if (into.filled(field.name, "authentication.challenge.field.name", `${at}.name`, "a credentials field needs a name")) {
|
|
2488
|
+
into.require(!seen.has(field.name), "authentication.challenge.field.unique", `${at}.name`, "each credentials field has its own key in the submitted values");
|
|
2489
|
+
seen.add(field.name);
|
|
2490
|
+
}
|
|
2491
|
+
into.filled(field.label, "authentication.challenge.field.label", `${at}.label`, "a credentials field needs a label");
|
|
2492
|
+
into.oneOf(field.type, CREDENTIAL_FIELD_TYPES, "authentication.challenge.field.type", `${at}.type`);
|
|
2493
|
+
if (field.required !== undefined)
|
|
2494
|
+
into.require(typeof field.required === "boolean", "authentication.challenge.field.required", `${at}.required`, "required must be a boolean when present");
|
|
2495
|
+
if (field.autocomplete !== undefined)
|
|
2496
|
+
into.require(typeof field.autocomplete === "string", "authentication.challenge.field.autocomplete", `${at}.autocomplete`, "autocomplete must be a string when present");
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2389
2500
|
/**
|
|
2390
2501
|
* What `start()` or `complete()` answered: a challenge or a rejection from `start`, a login or a
|
|
2391
2502
|
* rejection from `complete`. A rejection carries a failure the host can show, and a login is held
|
|
@@ -2401,13 +2512,17 @@ export function validateAuthenticationResult(result, method, path = "result", co
|
|
|
2401
2512
|
validateAuthenticationFailureInto(result.failure, `${path}.failure`, into);
|
|
2402
2513
|
}
|
|
2403
2514
|
else if (method === "start" && result.status === "interaction-required") {
|
|
2404
|
-
|
|
2515
|
+
validateAuthenticationChallenge(result.challenge, `${path}.challenge`, into);
|
|
2516
|
+
into.require(result.failure === undefined, "authentication.failure.unexpected", `${path}.failure`, "an authentication challenge carries no failure");
|
|
2405
2517
|
}
|
|
2406
2518
|
else if (method === "complete" && result.status === "authenticated") {
|
|
2407
2519
|
into.violations.push(...validateAuthenticationState(result, path, context));
|
|
2408
2520
|
}
|
|
2409
2521
|
else {
|
|
2410
|
-
into.add("authentication.result.status", `${path}.status`, `${method} answers ${method === "start" ? "interaction-required" : "authenticated"} or rejected, not ${
|
|
2522
|
+
into.add("authentication.result.status", `${path}.status`, `${method} answers ${method === "start" ? "interaction-required" : "authenticated"} or rejected, not ${describeValue(result.status)}`);
|
|
2523
|
+
}
|
|
2524
|
+
if (result.status !== "interaction-required") {
|
|
2525
|
+
into.require(result.challenge === undefined, "authentication.challenge.unexpected", `${path}.challenge`, "only interaction-required carries a challenge");
|
|
2411
2526
|
}
|
|
2412
2527
|
return into.violations;
|
|
2413
2528
|
}
|