@xema/omni-protocol 0.1.78 → 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/testing.js +18 -8
- package/dist/validation.js +155 -66
- package/guide.md +13 -14
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -126,11 +126,18 @@ const request = { reasonId, requestedAt } satisfies BreakRequest; // error: requ
|
|
|
126
126
|
## Building
|
|
127
127
|
|
|
128
128
|
```
|
|
129
|
-
pnpm install
|
|
129
|
+
pnpm install --frozen-lockfile
|
|
130
|
+
pnpm check # clean build, type checks, tests, and packed-package checks
|
|
130
131
|
pnpm build # emits dist/
|
|
131
132
|
pnpm test # type-checks the tests, then runs them
|
|
133
|
+
pnpm check:package # verifies the current dist/ as a consumer would
|
|
132
134
|
```
|
|
133
135
|
|
|
136
|
+
CI runs the complete check on Node 20, 22, and 24 for pull requests (including drafts)
|
|
137
|
+
and pushes to main. Package checks create a temporary npm tarball, verify its contents,
|
|
138
|
+
and check JavaScript imports and TypeScript declarations for every exported entry point
|
|
139
|
+
from an isolated consumer. Publishing also runs `pnpm check` through `prepublishOnly`.
|
|
140
|
+
|
|
134
141
|
## The guide is authoritative
|
|
135
142
|
|
|
136
143
|
Where `guide.md` and any code here disagree, the guide is right and the code is a defect. It has
|
package/dist/testing.js
CHANGED
|
@@ -1251,7 +1251,7 @@ export class TaskStream {
|
|
|
1251
1251
|
this.noteDials(event.task);
|
|
1252
1252
|
break;
|
|
1253
1253
|
case "task-media-started":
|
|
1254
|
-
ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.allocation.ended", "stream.allocation.unknown");
|
|
1254
|
+
ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.taskMedia.channel", "stream.allocation.ended", "stream.allocation.unknown");
|
|
1255
1255
|
if (id === undefined)
|
|
1256
1256
|
break;
|
|
1257
1257
|
if (known === undefined) {
|
|
@@ -1262,6 +1262,10 @@ export class TaskStream {
|
|
|
1262
1262
|
break;
|
|
1263
1263
|
// Ring-back is audio: a task whose party the host is dialling has media from dialling on,
|
|
1264
1264
|
// whatever its phase; every other task not at work has none.
|
|
1265
|
+
if (known.channel !== "voice") {
|
|
1266
|
+
refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
|
|
1267
|
+
break;
|
|
1268
|
+
}
|
|
1265
1269
|
if (!AT_WORK.has(known.phase) && !known.partyRingingByHost) {
|
|
1266
1270
|
refuse("stream.taskMediaStarted.beforeWork", `${at}.taskId`, `media cannot arrive on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
1267
1271
|
}
|
|
@@ -1271,7 +1275,7 @@ export class TaskStream {
|
|
|
1271
1275
|
known.media = "started";
|
|
1272
1276
|
break;
|
|
1273
1277
|
case "task-media-ended":
|
|
1274
|
-
ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.allocation.ended", "stream.allocation.unknown");
|
|
1278
|
+
ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.taskMedia.channel", "stream.allocation.ended", "stream.allocation.unknown");
|
|
1275
1279
|
if (id === undefined)
|
|
1276
1280
|
break;
|
|
1277
1281
|
if (known === undefined) {
|
|
@@ -1280,6 +1284,10 @@ export class TaskStream {
|
|
|
1280
1284
|
}
|
|
1281
1285
|
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1282
1286
|
break;
|
|
1287
|
+
if (known.channel !== "voice") {
|
|
1288
|
+
refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
|
|
1289
|
+
break;
|
|
1290
|
+
}
|
|
1283
1291
|
if (!WORK_BEGUN.has(known.phase)) {
|
|
1284
1292
|
refuse("stream.taskMediaEnded.beforeWork", `${at}.taskId`, `media cannot end on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
1285
1293
|
}
|
|
@@ -1477,10 +1485,11 @@ async function driveOneCall(drive) {
|
|
|
1477
1485
|
session = undefined;
|
|
1478
1486
|
ruleEvaluated("drive.reload.openMedia");
|
|
1479
1487
|
const reopened = await second.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
|
|
1480
|
-
|
|
1481
|
-
|
|
1488
|
+
const malformed = validateResult(reopened, "openMedia", "drive.reload.openMedia");
|
|
1489
|
+
found.push(...malformed);
|
|
1490
|
+
if (malformed.length === 0 && isRecord(reopened) && reopened.status === "opened")
|
|
1482
1491
|
session = reopened.session;
|
|
1483
|
-
else
|
|
1492
|
+
else if (malformed.length === 0)
|
|
1484
1493
|
refuse("drive.reload.openMedia", "drive.reload.openMedia", `a second adapter built from the same login could not open the audio of ${taskId}, which its own snapshot carries with media started`);
|
|
1485
1494
|
}
|
|
1486
1495
|
}
|
|
@@ -1630,10 +1639,11 @@ async function driveOneCall(drive) {
|
|
|
1630
1639
|
cursor = Math.max(cursor, started.at);
|
|
1631
1640
|
}
|
|
1632
1641
|
const opened = await drive.connection.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
|
|
1633
|
-
|
|
1634
|
-
|
|
1642
|
+
const malformed = validateResult(opened, "openMedia", "drive.openMedia");
|
|
1643
|
+
found.push(...malformed);
|
|
1644
|
+
if (malformed.length === 0 && isRecord(opened) && opened.status === "opened")
|
|
1635
1645
|
session = opened.session;
|
|
1636
|
-
else if (isRecord(opened))
|
|
1646
|
+
else if (malformed.length === 0 && isRecord(opened))
|
|
1637
1647
|
refuse("drive.openMedia.unavailable", "drive.openMedia", "a softphone login's adapter could not open the call's audio");
|
|
1638
1648
|
}
|
|
1639
1649
|
// 3b. The microphone is the host's. With the audio open, the drive mutes it for a moment and
|
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
|
});
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -309,7 +333,7 @@ function validateDialOutcomes(manifest, path, into) {
|
|
|
309
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");
|
|
310
334
|
return;
|
|
311
335
|
}
|
|
312
|
-
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`))
|
|
313
337
|
return;
|
|
314
338
|
if (!Array.isArray(declared) || declared.length === 0) {
|
|
315
339
|
into.add("manifest.dialOutcomes.shape", path, "dialOutcomes is a non-empty array of the outcomes the platform distinguishes");
|
|
@@ -317,7 +341,7 @@ function validateDialOutcomes(manifest, path, into) {
|
|
|
317
341
|
}
|
|
318
342
|
declared.forEach((outcome, index) => {
|
|
319
343
|
if (into.oneOf(outcome, DIAL_OUTCOMES, "manifest.dialOutcome", `${path}[${index}]`) && declared.indexOf(outcome) !== index) {
|
|
320
|
-
into.add("manifest.dialOutcome.unique", `${path}[${index}]`, `duplicate dial outcome: ${
|
|
344
|
+
into.add("manifest.dialOutcome.unique", `${path}[${index}]`, `duplicate dial outcome: ${describeValue(outcome)}`);
|
|
321
345
|
}
|
|
322
346
|
});
|
|
323
347
|
into.require(declared.includes("answered"), "manifest.dialOutcomes.answered", path, "a dial that reached its destination is stated, never inferred: dialOutcomes includes answered");
|
|
@@ -335,7 +359,7 @@ function validateDialOutcomes(manifest, path, into) {
|
|
|
335
359
|
function validatePhones(manifest, path, into) {
|
|
336
360
|
const declared = manifest.phones;
|
|
337
361
|
if (manifest.channel !== "voice") {
|
|
338
|
-
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`);
|
|
339
363
|
return;
|
|
340
364
|
}
|
|
341
365
|
if (!Array.isArray(declared) || declared.length === 0) {
|
|
@@ -344,7 +368,7 @@ function validatePhones(manifest, path, into) {
|
|
|
344
368
|
}
|
|
345
369
|
declared.forEach((phone, index) => {
|
|
346
370
|
if (into.oneOf(phone, PHONES, "manifest.phone", `${path}[${index}]`) && declared.indexOf(phone) !== index) {
|
|
347
|
-
into.add("manifest.phone.unique", `${path}[${index}]`, `duplicate phone: ${
|
|
371
|
+
into.add("manifest.phone.unique", `${path}[${index}]`, `duplicate phone: ${describeValue(phone)}`);
|
|
348
372
|
}
|
|
349
373
|
});
|
|
350
374
|
}
|
|
@@ -357,13 +381,13 @@ export function validatePhone(value, manifest, path = "context.phone") {
|
|
|
357
381
|
const channel = isPlainObject(manifest) ? manifest.channel : undefined;
|
|
358
382
|
const phones = isPlainObject(manifest) && Array.isArray(manifest.phones) ? manifest.phones : [];
|
|
359
383
|
if (channel !== "voice") {
|
|
360
|
-
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`);
|
|
361
385
|
return into.violations;
|
|
362
386
|
}
|
|
363
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"))
|
|
364
388
|
return into.violations;
|
|
365
389
|
if (into.oneOf(value, PHONES, "context.phone", path)) {
|
|
366
|
-
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(", ")}`);
|
|
367
391
|
}
|
|
368
392
|
return into.violations;
|
|
369
393
|
}
|
|
@@ -400,7 +424,7 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
400
424
|
else {
|
|
401
425
|
methods.forEach((method, index) => {
|
|
402
426
|
if (into.oneOf(method, AUTHENTICATION_METHODS, "manifest.authenticationMethod", `${path}.authenticationMethods[${index}]`) && methods.indexOf(method) !== index) {
|
|
403
|
-
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)}`);
|
|
404
428
|
}
|
|
405
429
|
});
|
|
406
430
|
}
|
|
@@ -583,6 +607,10 @@ function validateCustomCapabilities(value, path, into) {
|
|
|
583
607
|
into.filled(field.name, "task.custom.prompt.field.name", `${where}.name`, "a prompt field needs a name");
|
|
584
608
|
into.filled(field.label, "task.custom.prompt.field.label", `${where}.label`, "a prompt field needs a label");
|
|
585
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");
|
|
586
614
|
});
|
|
587
615
|
}
|
|
588
616
|
}
|
|
@@ -649,7 +677,7 @@ function validateLockedByInto(value, rule, path, levels, into) {
|
|
|
649
677
|
if (!into.filled(value, rule, path, "lockedBy names the level that locked it"))
|
|
650
678
|
return;
|
|
651
679
|
into.require(value !== "person", `${rule}.person`, path, "a person never locks their own value");
|
|
652
|
-
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(", ")}`);
|
|
653
681
|
}
|
|
654
682
|
/** `{ lockedBy, reason? }` standing in for a value: who locked it, and a reason if given. */
|
|
655
683
|
function validateLockedInto(value, rule, path, levels, into) {
|
|
@@ -660,7 +688,7 @@ function validateLockedInto(value, rule, path, levels, into) {
|
|
|
660
688
|
/** What every resolved value carries: who set it, and who locked it if anyone. */
|
|
661
689
|
function validateResolvedInto(value, rule, path, levels, into) {
|
|
662
690
|
if (into.filled(value.setBy, `${rule}.setBy`, `${path}.setBy`, "setBy names who stated the value: a level, or provider")) {
|
|
663
|
-
into.require(value.setBy === "provider" || levelIds(levels).includes(value.setBy), `${rule}.setBy.unknown`, `${path}.setBy`, `${
|
|
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`);
|
|
664
692
|
}
|
|
665
693
|
if (value.lockedBy !== undefined)
|
|
666
694
|
validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, levels, into);
|
|
@@ -771,7 +799,7 @@ function validateTaskAttributes(value, path, into, levels) {
|
|
|
771
799
|
into.timestamp(attribute.at, "task.attribute.timestamp", `${at}.at`);
|
|
772
800
|
break;
|
|
773
801
|
default:
|
|
774
|
-
into.add("task.attribute.type", `${at}.type`, `unsupported attribute type: ${
|
|
802
|
+
into.add("task.attribute.type", `${at}.type`, `unsupported attribute type: ${describeValue(attribute.type)}`);
|
|
775
803
|
}
|
|
776
804
|
});
|
|
777
805
|
}
|
|
@@ -822,7 +850,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
|
|
|
822
850
|
// the task is paused, and a mute only while its media is up. An open entry after that is a leg
|
|
823
851
|
// nobody closed, which reads exactly like a leg running now.
|
|
824
852
|
if (entry.step === "held") {
|
|
825
|
-
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`);
|
|
826
854
|
}
|
|
827
855
|
if (entry.step === "muted") {
|
|
828
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");
|
|
@@ -849,7 +877,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
|
|
|
849
877
|
if (entry[field] === undefined)
|
|
850
878
|
continue;
|
|
851
879
|
if (into.filled(entry[field], `task.handlingHistory.${field}`, `${at}.${field}`, `${field} must not be empty when present`)) {
|
|
852
|
-
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`);
|
|
853
881
|
}
|
|
854
882
|
}
|
|
855
883
|
});
|
|
@@ -871,7 +899,7 @@ function validateTaskMedia(task, value, channel, phase, path, into) {
|
|
|
871
899
|
// work has begun, on a snapshot as on the event, or a host would open the microphone on an offer.
|
|
872
900
|
// The one task not yet at work that has audio is one whose party the host is dialling: ring-back
|
|
873
901
|
// is audio, and every host-placed dial's media starts on dialling.
|
|
874
|
-
into.require(!WORK_NOT_BEGUN.includes(phase) || partyRingingByHost(task), "task.media.beforeWork", path, `a ${
|
|
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`);
|
|
875
903
|
}
|
|
876
904
|
}
|
|
877
905
|
const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
|
|
@@ -1079,7 +1107,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1079
1107
|
}
|
|
1080
1108
|
// The channel is fixed per provider by its manifest, so a task claiming another one is a
|
|
1081
1109
|
// task Omni would render with the wrong controls.
|
|
1082
|
-
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`);
|
|
1083
1111
|
if (task.reference !== undefined) {
|
|
1084
1112
|
into.filled(task.reference, "task.reference", `${path}.reference`, "a reference must not be empty when present");
|
|
1085
1113
|
}
|
|
@@ -1223,7 +1251,7 @@ function validateBreakState(value, path, into) {
|
|
|
1223
1251
|
// An imposed break is a break somebody placed; beside `not-requested` there is no break.
|
|
1224
1252
|
// An imposed break is a break in progress or about to be: it travels with in-effect or
|
|
1225
1253
|
// starting-after-task and nothing else. Beside granted, the host would commit a break nobody asked for.
|
|
1226
|
-
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; ${
|
|
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`);
|
|
1227
1255
|
}
|
|
1228
1256
|
if (value.reasons === undefined)
|
|
1229
1257
|
return;
|
|
@@ -1263,7 +1291,7 @@ function validateBreakState(value, path, into) {
|
|
|
1263
1291
|
// rules -- who may monitor through it, whether it counts -- nobody can apply.
|
|
1264
1292
|
ruleEvaluated("break.activeReasonId.required");
|
|
1265
1293
|
if (seen.size > 0 && (value.approval === "in-effect" || value.approval === "starting-after-task")) {
|
|
1266
|
-
into.require(typeof value.activeReasonId === "string" && value.activeReasonId.length > 0, "break.activeReasonId.required", `${path}.activeReasonId`, `a break ${
|
|
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`);
|
|
1267
1295
|
}
|
|
1268
1296
|
}
|
|
1269
1297
|
export function validateTeamRoster(roster, path = "team", context = {}) {
|
|
@@ -1370,10 +1398,10 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1370
1398
|
}
|
|
1371
1399
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
1372
1400
|
const levels = context.levels ?? manifestLevels(manifest);
|
|
1373
|
-
into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.
|
|
1401
|
+
into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.transport`);
|
|
1374
1402
|
if (into.filled(snapshot.loginId, "snapshot.loginId", `${path}.loginId`, "a snapshot needs the login id it belongs to")
|
|
1375
1403
|
&& context.loginId !== undefined) {
|
|
1376
|
-
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}`);
|
|
1377
1405
|
}
|
|
1378
1406
|
validateBreakState(snapshot.break, `${path}.break`, into);
|
|
1379
1407
|
// The count is the provider's confirmation of how much work it answered with. Stated, never
|
|
@@ -1440,7 +1468,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1440
1468
|
const reasons = Array.isArray(state.reasons) ? state.reasons : [];
|
|
1441
1469
|
const active = reasons.find((reason) => isPlainObject(reason) && reason.id === state.activeReasonId);
|
|
1442
1470
|
const kind = isPlainObject(active) ? active.kind : undefined;
|
|
1443
|
-
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)}`);
|
|
1444
1472
|
}
|
|
1445
1473
|
}
|
|
1446
1474
|
// Presence is the permission, and it cuts both ways: data a provider never declared a
|
|
@@ -1535,7 +1563,7 @@ function validateTaskOutcome(value, path, into) {
|
|
|
1535
1563
|
}
|
|
1536
1564
|
break;
|
|
1537
1565
|
default:
|
|
1538
|
-
into.add("event.taskEnded.outcome.type", `${path}.type`, `unsupported outcome: ${
|
|
1566
|
+
into.add("event.taskEnded.outcome.type", `${path}.type`, `unsupported outcome: ${describeValue(value.type)}`);
|
|
1539
1567
|
}
|
|
1540
1568
|
}
|
|
1541
1569
|
function validateQueueSummary(value, path, into) {
|
|
@@ -1583,7 +1611,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1583
1611
|
into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
|
|
1584
1612
|
if (into.filled(envelope.loginId, "event.loginId", `${path}.loginId`, "an event needs the login id it belongs to")
|
|
1585
1613
|
&& context.loginId !== undefined) {
|
|
1586
|
-
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}`);
|
|
1587
1615
|
}
|
|
1588
1616
|
const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
|
|
1589
1617
|
into.timestamp(envelope.occurredAt, "event.occurredAt", `${path}.occurredAt`);
|
|
@@ -1629,10 +1657,12 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1629
1657
|
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1630
1658
|
break;
|
|
1631
1659
|
case "task-media-started":
|
|
1660
|
+
into.require(channel === "voice", "event.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
|
|
1632
1661
|
into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
|
|
1633
1662
|
into.require(isAllocationId(event.allocationId), "event.taskMediaStarted.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
|
|
1634
1663
|
break;
|
|
1635
1664
|
case "task-media-ended":
|
|
1665
|
+
into.require(channel === "voice", "event.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
|
|
1636
1666
|
into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
1637
1667
|
into.require(isAllocationId(event.allocationId), "event.taskMediaEnded.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
|
|
1638
1668
|
break;
|
|
@@ -1646,7 +1676,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1646
1676
|
// Only a distinction the manifest declared: an undeclared word is a guess dressed as a code.
|
|
1647
1677
|
const declared = isPlainObject(manifest) && Array.isArray(manifest.dialOutcomes) ? manifest.dialOutcomes : undefined;
|
|
1648
1678
|
if (into.oneOf(event.outcome, DIAL_OUTCOMES, "event.dialOutcome.outcome", `${at}.outcome`)) {
|
|
1649
|
-
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`);
|
|
1650
1680
|
}
|
|
1651
1681
|
if (event.taskId !== undefined)
|
|
1652
1682
|
into.require(isTaskId(event.taskId), "event.dialOutcome.taskId", `${at}.taskId`, "taskId must be a task id when present");
|
|
@@ -1706,7 +1736,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1706
1736
|
}
|
|
1707
1737
|
break;
|
|
1708
1738
|
default:
|
|
1709
|
-
into.add("event.type", `${at}.type`, `unsupported event type: ${
|
|
1739
|
+
into.add("event.type", `${at}.type`, `unsupported event type: ${describeValue(event.type)}`);
|
|
1710
1740
|
}
|
|
1711
1741
|
return into.violations;
|
|
1712
1742
|
}
|
|
@@ -1942,7 +1972,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1942
1972
|
into.require(input.mutedBy === undefined, "host.audio.input.mutedBy.unexpected", `${at}.mutedBy`, "an unavailable input was not silenced; it is absent");
|
|
1943
1973
|
}
|
|
1944
1974
|
else {
|
|
1945
|
-
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)}`);
|
|
1946
1976
|
}
|
|
1947
1977
|
const output = report.audio.output;
|
|
1948
1978
|
const out = `${path}.audio.output`;
|
|
@@ -1965,7 +1995,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1965
1995
|
into.require(output.mutedBy === undefined, "host.audio.output.mutedBy.unexpected", `${out}.mutedBy`, "an unavailable output was not silenced; it is absent");
|
|
1966
1996
|
}
|
|
1967
1997
|
else {
|
|
1968
|
-
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)}`);
|
|
1969
1999
|
}
|
|
1970
2000
|
return into.violations;
|
|
1971
2001
|
}
|
|
@@ -1996,25 +2026,8 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1996
2026
|
: [...new Set([...TASK_COMMAND_NAMES.voice, ...TASK_COMMAND_NAMES.chat, ...TASK_COMMAND_NAMES.email])];
|
|
1997
2027
|
if (type === "custom") {
|
|
1998
2028
|
into.filled(command.name, "command.custom.name", `${path}.name`, "a custom command names the control it presses");
|
|
1999
|
-
if (task === undefined)
|
|
2000
|
-
return into.violations;
|
|
2001
|
-
// Held to the task: the control it presses is one the task published, and what the control asked for travels with it.
|
|
2002
|
-
const custom = isPlainObject(task) && isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
|
|
2003
|
-
const control = Array.isArray(custom) ? custom.filter(isPlainObject).find(entry => entry.id === command.name) : undefined;
|
|
2004
|
-
if (control === undefined) {
|
|
2005
|
-
into.add("command.capability.custom", path, `custom ${String(command.name)} presses a control the task never published`);
|
|
2006
|
-
return into.violations;
|
|
2007
|
-
}
|
|
2008
|
-
if (isPlainObject(control.prompt) && Array.isArray(control.prompt.fields)) {
|
|
2009
|
-
for (const field of control.prompt.fields.filter(isPlainObject)) {
|
|
2010
|
-
if (typeof field.name === "string") {
|
|
2011
|
-
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`);
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
return into.violations;
|
|
2016
2029
|
}
|
|
2017
|
-
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`)) {
|
|
2018
2031
|
return into.violations;
|
|
2019
2032
|
}
|
|
2020
2033
|
const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
|
|
@@ -2032,7 +2045,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2032
2045
|
into.filled(command.destinationId, "command.transfer.destinationId", `${path}.destinationId`, "a transfer names the directory item it goes to");
|
|
2033
2046
|
}
|
|
2034
2047
|
else {
|
|
2035
|
-
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`);
|
|
2036
2049
|
}
|
|
2037
2050
|
}
|
|
2038
2051
|
break;
|
|
@@ -2066,7 +2079,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2066
2079
|
default:
|
|
2067
2080
|
break;
|
|
2068
2081
|
}
|
|
2069
|
-
if (
|
|
2082
|
+
if (task === undefined)
|
|
2070
2083
|
return into.violations;
|
|
2071
2084
|
// The task the command is held to has to be one the wire published. A host's own mapping of it,
|
|
2072
2085
|
// handed in for want of the original, is not, and checking a command against it would be
|
|
@@ -2074,25 +2087,45 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2074
2087
|
// only to a task that stands.
|
|
2075
2088
|
const published = new Collector();
|
|
2076
2089
|
validateTaskInto(task, { channel: channel ?? "voice" }, `${path}.task`, published);
|
|
2077
|
-
if (published.violations.length > 0) {
|
|
2090
|
+
if (published.violations.length > 0 || !isPlainObject(task)) {
|
|
2078
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`);
|
|
2079
2092
|
return into.violations;
|
|
2080
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
|
+
}
|
|
2081
2114
|
// What the task has to offer or be in for the command to be issuable.
|
|
2082
2115
|
const capabilities = isPlainObject(task.capabilities) ? task.capabilities : {};
|
|
2083
2116
|
const offered = (name) => {
|
|
2084
2117
|
const declared = capabilities[name];
|
|
2085
2118
|
if (declared === undefined) {
|
|
2086
|
-
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`);
|
|
2087
2120
|
return false;
|
|
2088
2121
|
}
|
|
2089
2122
|
if (isLocked(declared)) {
|
|
2090
|
-
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`);
|
|
2091
2124
|
return false;
|
|
2092
2125
|
}
|
|
2093
2126
|
return true;
|
|
2094
2127
|
};
|
|
2095
|
-
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)}`);
|
|
2096
2129
|
// A control on the contact acts on a contact being handled. Before in-progress there is nothing
|
|
2097
2130
|
// to act on yet, and in completing the handling has ended: a call with nobody on it, a
|
|
2098
2131
|
// conversation closed. What a capability offers, the phase decides whether there is anything to use it on.
|
|
@@ -2101,7 +2134,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2101
2134
|
const listed = (name) => {
|
|
2102
2135
|
const declared = capabilities[name];
|
|
2103
2136
|
const directory = isPlainObject(declared) && Array.isArray(declared.destinations) ? declared.destinations.filter(isPlainObject) : [];
|
|
2104
|
-
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`);
|
|
2105
2138
|
};
|
|
2106
2139
|
const onCall = Array.isArray(task.onCall) ? task.onCall.filter(isPlainObject) : [];
|
|
2107
2140
|
switch (type) {
|
|
@@ -2144,7 +2177,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2144
2177
|
listed("warmTransfer");
|
|
2145
2178
|
}
|
|
2146
2179
|
else if (command.action === "complete" || command.action === "cancel") {
|
|
2147
|
-
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`);
|
|
2148
2181
|
}
|
|
2149
2182
|
break;
|
|
2150
2183
|
case "lead-assist":
|
|
@@ -2156,7 +2189,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2156
2189
|
}
|
|
2157
2190
|
}
|
|
2158
2191
|
else {
|
|
2159
|
-
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`);
|
|
2160
2193
|
}
|
|
2161
2194
|
break;
|
|
2162
2195
|
case "conference": {
|
|
@@ -2170,7 +2203,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2170
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");
|
|
2171
2204
|
}
|
|
2172
2205
|
else if (isFilled(command.destinationId)) {
|
|
2173
|
-
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)}`);
|
|
2174
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");
|
|
2175
2208
|
}
|
|
2176
2209
|
}
|
|
@@ -2185,9 +2218,12 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2185
2218
|
into.require(command.disposition === undefined, "command.complete.disposition.unexpected", `${path}.disposition`, "the task declares no dispositions; complete travels with no code");
|
|
2186
2219
|
into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task declares no dispositions; complete travels with no notes");
|
|
2187
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
|
+
}
|
|
2188
2224
|
else if (isPlainObject(dispositions)) {
|
|
2189
2225
|
if (Array.isArray(dispositions.codes) && command.disposition !== undefined) {
|
|
2190
|
-
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`);
|
|
2191
2227
|
}
|
|
2192
2228
|
if (dispositions.required === true) {
|
|
2193
2229
|
into.require(command.disposition !== undefined, "command.complete.disposition.required", `${path}.disposition`, "the task requires a disposition and complete carries none");
|
|
@@ -2241,14 +2277,27 @@ export function validateResult(result, method, path = "result", dialId) {
|
|
|
2241
2277
|
into.add("result.shape", path, `${method} must answer an object`);
|
|
2242
2278
|
return into.violations;
|
|
2243
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
|
+
}
|
|
2244
2283
|
if (result.status === statuses.success) {
|
|
2245
2284
|
into.require(result.failure === undefined, "result.failure.unexpected", `${path}.failure`, `${statuses.success} carries no failure`);
|
|
2246
2285
|
if (method === "openMedia") {
|
|
2247
|
-
|
|
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
|
+
}
|
|
2248
2297
|
}
|
|
2249
2298
|
if (dials) {
|
|
2250
2299
|
if (into.filled(result.dialId, "result.dialId", `${path}.dialId`, "dialling restates the dialId the host sent") && dialId !== undefined) {
|
|
2251
|
-
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)}`);
|
|
2252
2301
|
}
|
|
2253
2302
|
}
|
|
2254
2303
|
else {
|
|
@@ -2264,7 +2313,7 @@ export function validateResult(result, method, path = "result", dialId) {
|
|
|
2264
2313
|
}
|
|
2265
2314
|
}
|
|
2266
2315
|
else {
|
|
2267
|
-
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)}`);
|
|
2268
2317
|
}
|
|
2269
2318
|
return into.violations;
|
|
2270
2319
|
}
|
|
@@ -2377,7 +2426,7 @@ function validateMonitorControl(value, path, into) {
|
|
|
2377
2426
|
}
|
|
2378
2427
|
value.forEach((mode, index) => {
|
|
2379
2428
|
if (into.oneOf(mode, MONITOR_MODES, "authentication.capability.team.monitorControl.mode", `${path}[${index}]`) && value.indexOf(mode) !== index) {
|
|
2380
|
-
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)}`);
|
|
2381
2430
|
}
|
|
2382
2431
|
});
|
|
2383
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");
|
|
@@ -2394,7 +2443,7 @@ function validateAuthenticationFailureInto(value, path, into) {
|
|
|
2394
2443
|
return;
|
|
2395
2444
|
}
|
|
2396
2445
|
if (into.filled(value.code, "authentication.failure.code", `${path}.code`, "a failure needs a code") && value.code.startsWith("omni.")) {
|
|
2397
|
-
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`);
|
|
2398
2447
|
}
|
|
2399
2448
|
into.filled(value.message, "authentication.failure.message", `${path}.message`, "a failure needs a message");
|
|
2400
2449
|
into.require(typeof value.retryable === "boolean", "authentication.failure.retryable", `${path}.retryable`, "a failure must say whether it is retryable");
|
|
@@ -2402,7 +2451,7 @@ function validateAuthenticationFailureInto(value, path, into) {
|
|
|
2402
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");
|
|
2403
2452
|
}
|
|
2404
2453
|
if (value.retryAfterMs !== undefined) {
|
|
2405
|
-
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");
|
|
2406
2455
|
}
|
|
2407
2456
|
if (value.field !== undefined)
|
|
2408
2457
|
into.filled(value.field, "authentication.failure.field", `${path}.field`, "field names a declared credentials field when present");
|
|
@@ -2412,6 +2461,42 @@ export function validateAuthenticationFailure(failure, path = "failure") {
|
|
|
2412
2461
|
validateAuthenticationFailureInto(failure, path, into);
|
|
2413
2462
|
return into.violations;
|
|
2414
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
|
+
}
|
|
2415
2500
|
/**
|
|
2416
2501
|
* What `start()` or `complete()` answered: a challenge or a rejection from `start`, a login or a
|
|
2417
2502
|
* rejection from `complete`. A rejection carries a failure the host can show, and a login is held
|
|
@@ -2427,13 +2512,17 @@ export function validateAuthenticationResult(result, method, path = "result", co
|
|
|
2427
2512
|
validateAuthenticationFailureInto(result.failure, `${path}.failure`, into);
|
|
2428
2513
|
}
|
|
2429
2514
|
else if (method === "start" && result.status === "interaction-required") {
|
|
2430
|
-
|
|
2515
|
+
validateAuthenticationChallenge(result.challenge, `${path}.challenge`, into);
|
|
2516
|
+
into.require(result.failure === undefined, "authentication.failure.unexpected", `${path}.failure`, "an authentication challenge carries no failure");
|
|
2431
2517
|
}
|
|
2432
2518
|
else if (method === "complete" && result.status === "authenticated") {
|
|
2433
2519
|
into.violations.push(...validateAuthenticationState(result, path, context));
|
|
2434
2520
|
}
|
|
2435
2521
|
else {
|
|
2436
|
-
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");
|
|
2437
2526
|
}
|
|
2438
2527
|
return into.violations;
|
|
2439
2528
|
}
|
package/guide.md
CHANGED
|
@@ -1306,7 +1306,7 @@ dropped. An adapter that wanted only a better noun loses a field it never meant
|
|
|
1306
1306
|
|
|
1307
1307
|
## Protocol contract rules
|
|
1308
1308
|
|
|
1309
|
-
|
|
1309
|
+
Ten rules govern protocol data and behavior.
|
|
1310
1310
|
|
|
1311
1311
|
### 1. The protocol is authoritative
|
|
1312
1312
|
|
|
@@ -3181,7 +3181,7 @@ inside Omni.
|
|
|
3181
3181
|
|
|
3182
3182
|
## Every dial has an outcome
|
|
3183
3183
|
|
|
3184
|
-
|
|
3184
|
+
Five commands place a call: `dial` from the idle dialpad, `call` from preview, a cold or warm `transfer`, a
|
|
3185
3185
|
`conference` `add`, and `connect-back`. Each is accepted or refused at once, and each then ends later
|
|
3186
3186
|
and apart from its answer -- the destination picks up, is busy, or never does -- and a dial placed
|
|
3187
3187
|
late in a call routinely outlives the call. Nothing in between is reported: the wire says
|
|
@@ -3979,18 +3979,17 @@ decision or it is not.
|
|
|
3979
3979
|
|
|
3980
3980
|
## Real-time media
|
|
3981
3981
|
|
|
3982
|
-
Every voice provider has media
|
|
3983
|
-
|
|
3982
|
+
Every voice provider has media: `channel: "voice"` says audio exists. Where the agent hears it
|
|
3983
|
+
depends on the `phone` the host selected at authentication and connect from the manifest's
|
|
3984
|
+
`phones`. On a softphone, audio lands in Omni; on a desk phone, it lands on the handset and Omni
|
|
3985
|
+
opens no audio. See **How the agent hears the call**. Media transitions are voice-only;
|
|
3986
|
+
chat and email publish none (`event.media.channel`, `stream.taskMedia.channel`).
|
|
3984
3987
|
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
no device on the snapshot, because there is no choice to record: audio for this agent arrives in
|
|
3989
|
-
Omni, and Omni registers the endpoint for it.
|
|
3988
|
+
The provider owns signalling and the platform's endpoint configuration. The host selects the
|
|
3989
|
+
declared phone mode; it does not enumerate or reconfigure the platform's devices. The selected
|
|
3990
|
+
mode stays with the login rather than being inferred from a snapshot or a device failure.
|
|
3990
3991
|
|
|
3991
|
-
|
|
3992
|
-
and it removes the branch that came with it: no command has to ask where the audio went before
|
|
3993
|
-
deciding who performs it. Nor does the audio ever stand in for the task: a task's presence and
|
|
3992
|
+
Nor does the audio ever stand in for the task: a task's presence and
|
|
3994
3993
|
phase follow the provider's reports about the work, and the media — attaching, moving through a
|
|
3995
3994
|
hold, a consult, a conference or a transfer, and ending — is transient beside it. See **A task is
|
|
3996
3995
|
never its audio** under **Task allocation lifecycle**.
|
|
@@ -4165,7 +4164,7 @@ carries as `audio.input.localAudio`, and absent while that input is `unavailable
|
|
|
4165
4164
|
bridges audio without a host-side input may ignore it; one that needs it and finds it absent
|
|
4166
4165
|
answers `unavailable` with a failure Omni shows the agent.
|
|
4167
4166
|
|
|
4168
|
-
**When to ask is the provider's word, not Omni's guess.** Omni opens media on `task-media-started`,
|
|
4167
|
+
**When to ask is the provider's word, not Omni's guess.** On a softphone login, Omni opens media on `task-media-started`,
|
|
4169
4168
|
and on a task arriving with `media: "started"` on a snapshot; it closes on `task-media-ended` and
|
|
4170
4169
|
when the task ends. Between those words, nothing Omni's own senses report — a stream that drops, a
|
|
4171
4170
|
track that ends — moves the task or its audio.
|
|
@@ -4302,7 +4301,7 @@ declared:
|
|
|
4302
4301
|
| `lead-assist` with `action: "request"` or `"cancel"` | The `leadAssist` capability. `cancel` needs a request standing -- `Task.leadAssist` with status `requested`. |
|
|
4303
4302
|
| `lead-assist` with `action: "take-over"` or `"leave"` | The lead's own task, on a call they joined -- `Task.assisting` present. An agent's task never has it, and a provider that receives either without it answers `failed`. |
|
|
4304
4303
|
| `transfer` with a destination, `conference` with `action: "add"` | Its capability, and a `destinationId` the directory offered: the id Omni sends is the id the provider published (`command.destination.unknown`). |
|
|
4305
|
-
| `custom` | A control the task published under `capabilities.custom`, by its `id` (`command.capability.custom`), carrying
|
|
4304
|
+
| `custom` | A control the task published under `capabilities.custom`, by its `id` (`command.capability.custom`), carrying a non-empty string for each `required` prompt field and strings for any optional fields supplied (`command.custom.prompt`). A toggle carries its target `on` boolean (`command.custom.on`). |
|
|
4306
4305
|
| Everything else | Its own named capability. |
|
|
4307
4306
|
|
|
4308
4307
|
**A control on the contact belongs to the handling phases**, `in-progress` and `paused`:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xema/omni-protocol",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.79",
|
|
4
4
|
"description": "The Omni protocol: the contract every provider adapter implements",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,7 +30,11 @@
|
|
|
30
30
|
"scripts": {
|
|
31
31
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
32
32
|
"build": "pnpm run clean && tsc -p tsconfig.json",
|
|
33
|
-
"
|
|
33
|
+
"typecheck": "tsc -p tsconfig.test.json",
|
|
34
|
+
"test": "pnpm run typecheck && vitest run",
|
|
35
|
+
"check:package": "node scripts/check-package.mjs",
|
|
36
|
+
"check": "pnpm run build && pnpm run test && pnpm run check:package",
|
|
37
|
+
"prepublishOnly": "pnpm run check"
|
|
34
38
|
},
|
|
35
39
|
"devDependencies": {
|
|
36
40
|
"@types/node": "^22.20.1",
|