@xema/omni-protocol 0.1.0
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/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/design.d.ts +49 -0
- package/dist/design.js +27 -0
- package/dist/index.d.ts +894 -0
- package/dist/index.js +185 -0
- package/dist/testing.d.ts +62 -0
- package/dist/testing.js +294 -0
- package/dist/validation.d.ts +21 -0
- package/dist/validation.js +940 -0
- package/guide.md +2935 -0
- package/package.json +52 -0
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
// Runtime validation for the approved protocol.
|
|
2
|
+
//
|
|
3
|
+
// An adapter is loaded from a separate package and may be compiled against a different protocol
|
|
4
|
+
// version, so its output is untrusted input. Every validator here takes `unknown` and returns
|
|
5
|
+
// every violation it found rather than throwing on the first, so a caller can report all of them
|
|
6
|
+
// at once.
|
|
7
|
+
//
|
|
8
|
+
// The contract's closed sets are declared as types in index.ts and needed here as runtime lists.
|
|
9
|
+
// Each list is pinned to its type both ways -- a member the type lacks, or a member the list
|
|
10
|
+
// lacks, fails to compile -- so what the validators accept cannot drift from what the
|
|
11
|
+
// declarations say.
|
|
12
|
+
import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, } from "./index.js";
|
|
13
|
+
export class ProtocolConformanceError extends Error {
|
|
14
|
+
violations;
|
|
15
|
+
constructor(violations, summary = "Adapter violates the Omni protocol") {
|
|
16
|
+
const detail = violations.map(violation => ` ${violation.rule} at ${violation.path}: ${violation.message}`).join("\n");
|
|
17
|
+
super(`${summary} (${violations.length} violation${violations.length === 1 ? "" : "s"}):\n${detail}`);
|
|
18
|
+
this.name = "ProtocolConformanceError";
|
|
19
|
+
this.violations = violations;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Throws `ProtocolConformanceError` when any violation is present. */
|
|
23
|
+
export function assertNoViolations(violations, summary) {
|
|
24
|
+
if (violations.length > 0)
|
|
25
|
+
throw new ProtocolConformanceError(violations, summary);
|
|
26
|
+
}
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// The contract's closed sets, as runtime lists pinned to their types.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
/**
|
|
31
|
+
* Every member of a contract union, as a list.
|
|
32
|
+
*
|
|
33
|
+
* `Record<U, true>` is what makes it complete: a key the union lacks is an excess property and a
|
|
34
|
+
* union member the object lacks is a missing one, so either mistake is a compile error here
|
|
35
|
+
* rather than a validator that quietly accepts or rejects the wrong thing.
|
|
36
|
+
*/
|
|
37
|
+
const membersOf = (members) => Object.keys(members);
|
|
38
|
+
const CHANNELS = membersOf({ voice: true, chat: true, email: true });
|
|
39
|
+
const TASK_PHASES = membersOf({
|
|
40
|
+
pending: true, confirmed: true, preparing: true, "in-progress": true, paused: true, completing: true,
|
|
41
|
+
});
|
|
42
|
+
const COMPLETION_MODES = membersOf({ "agent-command": true, "provider-automatic": true });
|
|
43
|
+
const ACCEPTANCE_MODES = membersOf({
|
|
44
|
+
"no-preference": true, "require-agent-acceptance": true, "require-automatic-acceptance": true,
|
|
45
|
+
});
|
|
46
|
+
const CONNECTION_STATUSES = membersOf({ connecting: true, active: true, error: true });
|
|
47
|
+
const AUTHENTICATION_METHODS = membersOf({ "browser-sso": true, credentials: true });
|
|
48
|
+
const AUTHENTICATION_STATUSES = membersOf({
|
|
49
|
+
"signed-out": true, authenticating: true, authenticated: true, refreshing: true, expired: true,
|
|
50
|
+
});
|
|
51
|
+
const BREAK_APPROVALS = membersOf({
|
|
52
|
+
"not-requested": true, "awaiting-decision": true, granted: true, "starting-after-task": true, "in-effect": true,
|
|
53
|
+
});
|
|
54
|
+
const TEAM_AVAILABILITIES = membersOf({
|
|
55
|
+
ready: true, "on-task": true, "on-break": true, "signed-out": true,
|
|
56
|
+
});
|
|
57
|
+
const HANDLING_STEPS = membersOf({
|
|
58
|
+
queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
|
|
59
|
+
});
|
|
60
|
+
const DESTINATION_KINDS = membersOf({ queue: true, agent: true, external: true });
|
|
61
|
+
const CUSTOM_UI_KINDS = membersOf({ button: true, toggle: true, "menu-item": true });
|
|
62
|
+
const CUSTOM_UI_PLACEMENTS = membersOf({ primary: true, secondary: true, overflow: true });
|
|
63
|
+
const NOTES_POLICIES = membersOf({ required: true, optional: true, hidden: true });
|
|
64
|
+
const ACCESS_MODES = membersOf({ "allow-all": true, "block-all": true });
|
|
65
|
+
const ACCESS_POLICY_SCOPES = membersOf({
|
|
66
|
+
"initial-url": true, "all-navigation": true,
|
|
67
|
+
});
|
|
68
|
+
const DIAL_DESTINATION_POLICIES = membersOf({ "contacts-only": true, "any-number": true });
|
|
69
|
+
const SNAPSHOT_REASONS = membersOf({
|
|
70
|
+
reconnected: true, "provider-requested": true,
|
|
71
|
+
});
|
|
72
|
+
const SESSION_CAPABILITIES = membersOf({ breaks: true, teamBreakControl: true });
|
|
73
|
+
const COMPLETED_BY = membersOf({ agent: true, provider: true });
|
|
74
|
+
const EXPIRABLE_PHASES = membersOf({
|
|
75
|
+
pending: true, confirmed: true, preparing: true,
|
|
76
|
+
});
|
|
77
|
+
const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
|
|
78
|
+
/** The capabilities each channel arm of `TaskCapabilities` declares, keyed off the type itself. */
|
|
79
|
+
const TASK_CAPABILITIES = {
|
|
80
|
+
voice: membersOf({
|
|
81
|
+
browsers: true, dispositions: true, custom: true, decline: true, mute: true, hold: true,
|
|
82
|
+
agentDisconnect: true, blindTransfer: true, conference: true, recording: true,
|
|
83
|
+
}),
|
|
84
|
+
chat: membersOf({ browsers: true, dispositions: true, custom: true, reject: true, hold: true }),
|
|
85
|
+
email: membersOf({ browsers: true, dispositions: true, custom: true, reject: true }),
|
|
86
|
+
};
|
|
87
|
+
/** Idle capabilities each channel may declare. Only voice may dial, and runtime has to say so too. */
|
|
88
|
+
const IDLE_CAPABILITIES_BY_CHANNEL = {
|
|
89
|
+
voice: IDLE_CAPABILITIES,
|
|
90
|
+
chat: IDLE_CAPABILITIES.filter(name => name !== "dial"),
|
|
91
|
+
email: IDLE_CAPABILITIES.filter(name => name !== "dial"),
|
|
92
|
+
};
|
|
93
|
+
const isChannel = (value) => CHANNELS.includes(value);
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Semantic types. Each is a primitive on the wire with its own validation rule.
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
98
|
+
const isFilled = (value) => typeof value === "string" && value.trim().length > 0;
|
|
99
|
+
/**
|
|
100
|
+
* An RFC-3339 timestamp carrying a zone.
|
|
101
|
+
*
|
|
102
|
+
* `Date.parse` accepts a timezone-less string and resolves it against whatever zone the machine
|
|
103
|
+
* happens to be in, so two hosts would read the same wire value as two different instants. The
|
|
104
|
+
* contract calls those invalid, and this is where that is enforced rather than assumed.
|
|
105
|
+
*/
|
|
106
|
+
const isIsoTimestamp = (value) => typeof value === "string"
|
|
107
|
+
&& /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/.test(value)
|
|
108
|
+
&& !Number.isNaN(Date.parse(value));
|
|
109
|
+
/** A non-negative integer count of seconds. */
|
|
110
|
+
const isDurationSeconds = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
111
|
+
/** Opaque, non-empty, provider-issued. Never parsed and never compared across providers. */
|
|
112
|
+
const isUserId = isFilled;
|
|
113
|
+
const isTaskId = isFilled;
|
|
114
|
+
class Collector {
|
|
115
|
+
violations = [];
|
|
116
|
+
add(rule, path, message) {
|
|
117
|
+
this.violations.push({ rule, path, message });
|
|
118
|
+
}
|
|
119
|
+
require(condition, rule, path, message) {
|
|
120
|
+
if (!condition)
|
|
121
|
+
this.add(rule, path, message);
|
|
122
|
+
return Boolean(condition);
|
|
123
|
+
}
|
|
124
|
+
filled(value, rule, path, message) {
|
|
125
|
+
return this.require(isFilled(value), rule, path, message);
|
|
126
|
+
}
|
|
127
|
+
timestamp(value, rule, path) {
|
|
128
|
+
return this.require(isIsoTimestamp(value), rule, path, "must be an RFC-3339 timestamp carrying a zone, such as 2026-08-21T09:00:00Z");
|
|
129
|
+
}
|
|
130
|
+
oneOf(value, allowed, rule, path) {
|
|
131
|
+
return this.require(typeof value === "string" && allowed.includes(value), rule, path, `must be one of ${allowed.join(", ")}; received ${String(value)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Shared shapes.
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
function validateAttributes(value, path, into) {
|
|
138
|
+
if (value === undefined)
|
|
139
|
+
return;
|
|
140
|
+
if (!Array.isArray(value)) {
|
|
141
|
+
into.add("attributes.shape", path, "attributes must be an array when present");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
value.forEach((attribute, index) => {
|
|
145
|
+
const at = `${path}[${index}]`;
|
|
146
|
+
if (!isPlainObject(attribute)) {
|
|
147
|
+
into.add("attribute.shape", at, "each attribute must be an object");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
into.filled(attribute.key, "attribute.key", `${at}.key`, "an attribute needs a non-empty key");
|
|
151
|
+
into.require(typeof attribute.value === "string", "attribute.value", `${at}.value`, "an attribute value must be a string");
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** Every field is optional, so this checks what is present rather than what is missing. */
|
|
155
|
+
export function validateContact(contact, path = "contact") {
|
|
156
|
+
const into = new Collector();
|
|
157
|
+
validateContactInto(contact, path, into);
|
|
158
|
+
return into.violations;
|
|
159
|
+
}
|
|
160
|
+
function validateContactInto(contact, path, into) {
|
|
161
|
+
if (!isPlainObject(contact)) {
|
|
162
|
+
into.add("contact.shape", path, "a contact must be an object");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
for (const field of ["name", "number", "email"]) {
|
|
166
|
+
if (contact[field] !== undefined) {
|
|
167
|
+
into.filled(contact[field], `contact.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
validateAttributes(contact.attributes, `${path}.attributes`, into);
|
|
171
|
+
}
|
|
172
|
+
export function validateScheduledActivity(activity, path = "scheduledActivity") {
|
|
173
|
+
const into = new Collector();
|
|
174
|
+
validateScheduledActivityInto(activity, path, into);
|
|
175
|
+
return into.violations;
|
|
176
|
+
}
|
|
177
|
+
function validateScheduledActivityInto(activity, path, into) {
|
|
178
|
+
if (!isPlainObject(activity)) {
|
|
179
|
+
into.add("activity.shape", path, "a scheduled activity must be an object");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
into.filled(activity.id, "activity.id", `${path}.id`, "a scheduled activity needs an id");
|
|
183
|
+
into.filled(activity.title, "activity.title", `${path}.title`, "a scheduled activity needs a title");
|
|
184
|
+
const startValid = into.timestamp(activity.startsAt, "activity.startsAt", `${path}.startsAt`);
|
|
185
|
+
if (activity.endsAt !== undefined) {
|
|
186
|
+
const endValid = into.timestamp(activity.endsAt, "activity.endsAt", `${path}.endsAt`);
|
|
187
|
+
if (startValid && endValid) {
|
|
188
|
+
into.require(Date.parse(activity.endsAt) >= Date.parse(activity.startsAt), "activity.endsAt.order", `${path}.endsAt`, "endsAt must not precede startsAt");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (activity.contact !== undefined)
|
|
192
|
+
validateContactInto(activity.contact, `${path}.contact`, into);
|
|
193
|
+
validateAttributes(activity.attributes, `${path}.attributes`, into);
|
|
194
|
+
}
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Manifest.
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
function validateBrowserAccessPolicy(value, path, into) {
|
|
199
|
+
if (!isPlainObject(value)) {
|
|
200
|
+
into.add("manifest.personalBrowser.access.shape", path, "an access policy must be an object");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
into.oneOf(value.mode, ACCESS_MODES, "manifest.personalBrowser.access.mode", `${path}.mode`);
|
|
204
|
+
for (const list of ["allowList", "blockList"]) {
|
|
205
|
+
if (value[list] === undefined)
|
|
206
|
+
continue;
|
|
207
|
+
if (!Array.isArray(value[list])) {
|
|
208
|
+
into.add("manifest.personalBrowser.access.list", `${path}.${list}`, `${list} must be an array when present`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
value[list].forEach((entry, index) => {
|
|
212
|
+
into.filled(entry, "manifest.personalBrowser.access.host", `${path}.${list}[${index}]`, "a host pattern must not be empty");
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function validateIdleCapabilities(value, channel, path, into) {
|
|
217
|
+
if (value === undefined)
|
|
218
|
+
return;
|
|
219
|
+
if (!isPlainObject(value)) {
|
|
220
|
+
into.add("manifest.idleCapabilities.shape", path, "idleCapabilities must be an object when present");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const allowed = isChannel(channel) ? IDLE_CAPABILITIES_BY_CHANNEL[channel] : IDLE_CAPABILITIES_BY_CHANNEL.voice;
|
|
224
|
+
for (const [name, declared] of Object.entries(value)) {
|
|
225
|
+
if (declared === undefined)
|
|
226
|
+
continue;
|
|
227
|
+
// Only voice may dial, and the channel arms are what make that a compile error. Runtime
|
|
228
|
+
// has to say the same thing, or an adapter compiled against a looser version slips through.
|
|
229
|
+
into.require(allowed.includes(name), "manifest.idleCapability.channel", `${path}.${name}`, `${channel} providers may not declare ${name}`);
|
|
230
|
+
}
|
|
231
|
+
if (value.personalBrowser !== undefined) {
|
|
232
|
+
const browser = value.personalBrowser;
|
|
233
|
+
if (!isPlainObject(browser)) {
|
|
234
|
+
into.add("manifest.personalBrowser.shape", `${path}.personalBrowser`, "personalBrowser must be an object when present");
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
validateBrowserAccessPolicy(browser.access, `${path}.personalBrowser.access`, into);
|
|
238
|
+
if (browser.accessPolicyScope !== undefined) {
|
|
239
|
+
into.oneOf(browser.accessPolicyScope, ACCESS_POLICY_SCOPES, "manifest.personalBrowser.accessPolicyScope", `${path}.personalBrowser.accessPolicyScope`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (value.dial !== undefined) {
|
|
244
|
+
if (!isPlainObject(value.dial)) {
|
|
245
|
+
into.add("manifest.dial.shape", `${path}.dial`, "dial must be an object when present");
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
into.oneOf(value.dial.destinationPolicy, DIAL_DESTINATION_POLICIES, "manifest.dial.destinationPolicy", `${path}.dial.destinationPolicy`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const flag of ["calendar", "contacts"]) {
|
|
252
|
+
if (value[flag] !== undefined) {
|
|
253
|
+
into.require(value[flag] === true, `manifest.idleCapability.value`, `${path}.${flag}`, `${flag} is declared by presence: send true or omit it`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
export function validateManifest(manifest, path = "manifest") {
|
|
258
|
+
const into = new Collector();
|
|
259
|
+
if (!isPlainObject(manifest)) {
|
|
260
|
+
into.add("manifest.shape", path, "a manifest must be an object");
|
|
261
|
+
return into.violations;
|
|
262
|
+
}
|
|
263
|
+
into.filled(manifest.id, "manifest.id", `${path}.id`, "a manifest needs a stable id");
|
|
264
|
+
into.filled(manifest.displayName, "manifest.displayName", `${path}.displayName`, "a manifest needs a display name");
|
|
265
|
+
const channelValid = into.oneOf(manifest.channel, CHANNELS, "manifest.channel", `${path}.channel`);
|
|
266
|
+
const versions = manifest.supportedProtocolVersions;
|
|
267
|
+
if (!Array.isArray(versions) || versions.length === 0) {
|
|
268
|
+
into.add("manifest.supportedProtocolVersions", `${path}.supportedProtocolVersions`, "an adapter must declare every protocol version it can speak");
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
versions.forEach((version, index) => {
|
|
272
|
+
into.require(typeof version === "number" && Number.isInteger(version) && version > 0, "manifest.supportedProtocolVersions.value", `${path}.supportedProtocolVersions[${index}]`, "a protocol version must be a positive integer");
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
const methods = manifest.authenticationMethods;
|
|
276
|
+
if (!Array.isArray(methods) || methods.length === 0) {
|
|
277
|
+
into.add("manifest.authenticationMethods", `${path}.authenticationMethods`, "an adapter must declare at least one authentication method");
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
methods.forEach((method, index) => {
|
|
281
|
+
into.oneOf(method, AUTHENTICATION_METHODS, "manifest.authenticationMethod", `${path}.authenticationMethods[${index}]`);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (channelValid)
|
|
285
|
+
validateIdleCapabilities(manifest.idleCapabilities, manifest.channel, `${path}.idleCapabilities`, into);
|
|
286
|
+
if (manifest.phaseLabels !== undefined) {
|
|
287
|
+
if (!isPlainObject(manifest.phaseLabels)) {
|
|
288
|
+
into.add("manifest.phaseLabels.shape", `${path}.phaseLabels`, "phaseLabels must be an object when present");
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
for (const [phase, label] of Object.entries(manifest.phaseLabels)) {
|
|
292
|
+
into.oneOf(phase, TASK_PHASES, "manifest.phaseLabels.phase", `${path}.phaseLabels.${phase}`);
|
|
293
|
+
into.filled(label, "manifest.phaseLabels.label", `${path}.phaseLabels.${phase}`, "a phase label must not be empty");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (manifest.taskTypePresentation !== undefined) {
|
|
298
|
+
if (!isPlainObject(manifest.taskTypePresentation)) {
|
|
299
|
+
into.add("manifest.taskTypePresentation.shape", `${path}.taskTypePresentation`, "taskTypePresentation must be an object when present");
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
for (const [taskType, presentation] of Object.entries(manifest.taskTypePresentation)) {
|
|
303
|
+
const at = `${path}.taskTypePresentation.${taskType}`;
|
|
304
|
+
if (!isPlainObject(presentation)) {
|
|
305
|
+
into.add("manifest.taskTypePresentation.entry", at, "each presentation must be an object");
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
into.filled(presentation.singular, "manifest.taskTypePresentation.singular", `${at}.singular`, "a presentation needs a singular name");
|
|
309
|
+
into.filled(presentation.plural, "manifest.taskTypePresentation.plural", `${at}.plural`, "a presentation needs a plural name");
|
|
310
|
+
if (presentation.referenceLabel !== undefined) {
|
|
311
|
+
into.filled(presentation.referenceLabel, "manifest.taskTypePresentation.referenceLabel", `${at}.referenceLabel`, "referenceLabel must not be empty when present");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return into.violations;
|
|
317
|
+
}
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Task.
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
function validateDestinationDirectory(value, path, into) {
|
|
322
|
+
if (value === true)
|
|
323
|
+
return;
|
|
324
|
+
if (!isPlainObject(value)) {
|
|
325
|
+
into.add("task.destinations.shape", path, "must be true or a destination directory");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
into.require(typeof value.allowManualEntry === "boolean", "task.destinations.allowManualEntry", `${path}.allowManualEntry`, "a directory must say whether manual entry is allowed");
|
|
329
|
+
if (value.destinations === undefined)
|
|
330
|
+
return;
|
|
331
|
+
if (!Array.isArray(value.destinations)) {
|
|
332
|
+
into.add("task.destinations.list", `${path}.destinations`, "destinations must be an array when present");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
value.destinations.forEach((destination, index) => {
|
|
336
|
+
const at = `${path}.destinations[${index}]`;
|
|
337
|
+
if (!isPlainObject(destination)) {
|
|
338
|
+
into.add("task.destination.shape", at, "each destination must be an object");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
into.filled(destination.id, "task.destination.id", `${at}.id`, "a destination needs an id");
|
|
342
|
+
into.filled(destination.label, "task.destination.label", `${at}.label`, "a destination needs a label");
|
|
343
|
+
into.filled(destination.address, "task.destination.address", `${at}.address`, "a destination needs an address");
|
|
344
|
+
into.oneOf(destination.kind, DESTINATION_KINDS, "task.destination.kind", `${at}.kind`);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
function validateDispositions(value, path, into) {
|
|
348
|
+
if (value === true)
|
|
349
|
+
return;
|
|
350
|
+
if (!isPlainObject(value)) {
|
|
351
|
+
into.add("task.dispositions.shape", path, "must be true or a disposition policy");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (value.required !== undefined) {
|
|
355
|
+
into.require(typeof value.required === "boolean", "task.dispositions.required", `${path}.required`, "required must be a boolean when present");
|
|
356
|
+
}
|
|
357
|
+
if (value.notes !== undefined)
|
|
358
|
+
into.oneOf(value.notes, NOTES_POLICIES, "task.dispositions.notes", `${path}.notes`);
|
|
359
|
+
if (value.codes === undefined)
|
|
360
|
+
return;
|
|
361
|
+
if (!Array.isArray(value.codes)) {
|
|
362
|
+
into.add("task.dispositions.codes", `${path}.codes`, "codes must be an array when present");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const seen = new Set();
|
|
366
|
+
value.codes.forEach((code, index) => {
|
|
367
|
+
const at = `${path}.codes[${index}]`;
|
|
368
|
+
if (!isPlainObject(code)) {
|
|
369
|
+
into.add("task.disposition.shape", at, "each disposition code must be an object");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (into.filled(code.id, "task.disposition.id", `${at}.id`, "a disposition code needs an id")) {
|
|
373
|
+
if (seen.has(code.id))
|
|
374
|
+
into.add("task.disposition.unique", `${at}.id`, `duplicate disposition code: ${code.id}`);
|
|
375
|
+
seen.add(code.id);
|
|
376
|
+
}
|
|
377
|
+
into.filled(code.label, "task.disposition.label", `${at}.label`, "a disposition code needs a label");
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
function validateCustomCapabilities(value, path, into) {
|
|
381
|
+
if (!Array.isArray(value)) {
|
|
382
|
+
into.add("task.custom.shape", path, "custom must be an array when present");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const seen = new Set();
|
|
386
|
+
value.forEach((custom, index) => {
|
|
387
|
+
const at = `${path}[${index}]`;
|
|
388
|
+
if (!isPlainObject(custom)) {
|
|
389
|
+
into.add("task.custom.entry", at, "each custom capability must be an object");
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (into.filled(custom.id, "task.custom.id", `${at}.id`, "a custom capability needs an id")) {
|
|
393
|
+
if (seen.has(custom.id))
|
|
394
|
+
into.add("task.custom.unique", `${at}.id`, `duplicate custom capability: ${custom.id}`);
|
|
395
|
+
seen.add(custom.id);
|
|
396
|
+
}
|
|
397
|
+
if (!isPlainObject(custom.ui)) {
|
|
398
|
+
into.add("task.custom.ui", `${at}.ui`, "a custom capability needs a ui description");
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
into.oneOf(custom.ui.kind, CUSTOM_UI_KINDS, "task.custom.ui.kind", `${at}.ui.kind`);
|
|
402
|
+
into.filled(custom.ui.label, "task.custom.ui.label", `${at}.ui.label`, "a custom control needs a label");
|
|
403
|
+
into.oneOf(custom.ui.placement, CUSTOM_UI_PLACEMENTS, "task.custom.ui.placement", `${at}.ui.placement`);
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
function validateBrowsers(value, path, into) {
|
|
407
|
+
if (!Array.isArray(value)) {
|
|
408
|
+
into.add("task.browsers.shape", path, "browsers must be an array");
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const seen = new Set();
|
|
412
|
+
value.forEach((browser, index) => {
|
|
413
|
+
const at = `${path}[${index}]`;
|
|
414
|
+
if (!isPlainObject(browser)) {
|
|
415
|
+
into.add("task.browser.shape", at, "each browser must be an object");
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (into.filled(browser.id, "task.browser.id", `${at}.id`, "a browser needs an id")) {
|
|
419
|
+
if (seen.has(browser.id))
|
|
420
|
+
into.add("task.browser.unique", `${at}.id`, `duplicate browser id: ${browser.id}`);
|
|
421
|
+
seen.add(browser.id);
|
|
422
|
+
}
|
|
423
|
+
into.filled(browser.name, "task.browser.name", `${at}.name`, "a browser needs a name");
|
|
424
|
+
into.filled(browser.purpose, "task.browser.purpose", `${at}.purpose`, "a browser needs a purpose");
|
|
425
|
+
if (into.filled(browser.url, "task.browser.url", `${at}.url`, "a browser needs a url")) {
|
|
426
|
+
let scheme;
|
|
427
|
+
try {
|
|
428
|
+
scheme = new URL(browser.url).protocol;
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
scheme = undefined;
|
|
432
|
+
}
|
|
433
|
+
into.require(scheme !== undefined && ALLOWED_BROWSER_URL_SCHEMES.includes(scheme), "task.browser.url.scheme", `${at}.url`, `a browser url must use ${ALLOWED_BROWSER_URL_SCHEMES.join(" or ")}`);
|
|
434
|
+
}
|
|
435
|
+
// Reuse and its scheme travel together. A reusing browser with no scheme would otherwise
|
|
436
|
+
// inherit whatever a host happened to default to, which is how two tasks end up sharing a
|
|
437
|
+
// session nobody intended.
|
|
438
|
+
if (browser.reuse === true) {
|
|
439
|
+
into.require(ISOLATION_SCHEME_VALUES.includes(browser.isolationScheme), "task.browser.isolationScheme", `${at}.isolationScheme`, `a reusing browser must declare one of: ${ISOLATION_SCHEME_VALUES.join(", ")}`);
|
|
440
|
+
}
|
|
441
|
+
else if (browser.reuse === false) {
|
|
442
|
+
into.require(browser.isolationScheme === undefined, "task.browser.isolationScheme.unexpected", `${at}.isolationScheme`, "a browser that does not reuse must not declare an isolation scheme");
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
into.add("task.browser.reuse", `${at}.reuse`, "a browser must say whether it reuses a session");
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
function validateTaskAttributes(value, path, into) {
|
|
450
|
+
if (value === undefined)
|
|
451
|
+
return;
|
|
452
|
+
if (!Array.isArray(value)) {
|
|
453
|
+
into.add("task.attributes.shape", path, "attributes must be an array when present");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
value.forEach((attribute, index) => {
|
|
457
|
+
const at = `${path}[${index}]`;
|
|
458
|
+
if (!isPlainObject(attribute)) {
|
|
459
|
+
into.add("task.attribute.shape", at, "each task attribute must be an object");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
into.filled(attribute.key, "task.attribute.key", `${at}.key`, "a task attribute needs a key");
|
|
463
|
+
if (attribute.label !== undefined) {
|
|
464
|
+
into.filled(attribute.label, "task.attribute.label", `${at}.label`, "a label must not be empty when present");
|
|
465
|
+
}
|
|
466
|
+
switch (attribute.type) {
|
|
467
|
+
case "text":
|
|
468
|
+
into.require(typeof attribute.value === "string", "task.attribute.text", `${at}.value`, "a text attribute needs a string value");
|
|
469
|
+
break;
|
|
470
|
+
case "contact":
|
|
471
|
+
validateContactInto(attribute.contact, `${at}.contact`, into);
|
|
472
|
+
break;
|
|
473
|
+
case "timestamp":
|
|
474
|
+
into.timestamp(attribute.at, "task.attribute.timestamp", `${at}.at`);
|
|
475
|
+
break;
|
|
476
|
+
default:
|
|
477
|
+
into.add("task.attribute.type", `${at}.type`, `unsupported attribute type: ${String(attribute.type)}`);
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
function validateHandlingHistory(value, path, into) {
|
|
482
|
+
if (value === undefined)
|
|
483
|
+
return;
|
|
484
|
+
if (!Array.isArray(value)) {
|
|
485
|
+
into.add("task.handlingHistory.shape", path, "handlingHistory must be an array when present");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
value.forEach((entry, index) => {
|
|
489
|
+
const at = `${path}[${index}]`;
|
|
490
|
+
if (!isPlainObject(entry)) {
|
|
491
|
+
into.add("task.handlingHistory.entry", at, "each handling step must be an object");
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
into.oneOf(entry.step, HANDLING_STEPS, "task.handlingHistory.step", `${at}.step`);
|
|
495
|
+
into.timestamp(entry.at, "task.handlingHistory.at", `${at}.at`);
|
|
496
|
+
if (entry.seconds !== undefined) {
|
|
497
|
+
// Omitted while a leg is still running. Nought is a claim that it took no time.
|
|
498
|
+
into.require(isDurationSeconds(entry.seconds) && entry.seconds > 0, "task.handlingHistory.seconds", `${at}.seconds`, "seconds must be a positive whole number; omit it while the step is still running");
|
|
499
|
+
}
|
|
500
|
+
if (entry.by !== undefined) {
|
|
501
|
+
into.require(isUserId(entry.by), "task.handlingHistory.by", `${at}.by`, "by must be a non-empty user id; omit it when the person cannot be identified");
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
export function validateTask(task, context, path = "task") {
|
|
506
|
+
const into = new Collector();
|
|
507
|
+
validateTaskInto(task, context, path, into);
|
|
508
|
+
return into.violations;
|
|
509
|
+
}
|
|
510
|
+
function validateTaskInto(task, context, path, into) {
|
|
511
|
+
if (!isPlainObject(task)) {
|
|
512
|
+
into.add("task.shape", path, "a task must be an object");
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
into.require(isTaskId(task.id), "task.id", `${path}.id`, "a task needs a non-empty id");
|
|
516
|
+
into.filled(task.title, "task.title", `${path}.title`, "a task needs a title");
|
|
517
|
+
into.filled(task.taskType, "task.taskType", `${path}.taskType`, "a task needs a task type");
|
|
518
|
+
into.oneOf(task.phase, TASK_PHASES, "task.phase", `${path}.phase`);
|
|
519
|
+
into.oneOf(task.completionMode, COMPLETION_MODES, "task.completionMode", `${path}.completionMode`);
|
|
520
|
+
into.require(isDurationSeconds(task.completionAllowance), "task.completionAllowance", `${path}.completionAllowance`, "completionAllowance must be a whole number of seconds, zero or more");
|
|
521
|
+
// The channel is fixed per provider by its manifest, so a task claiming another one is a
|
|
522
|
+
// task Omni would render with the wrong controls.
|
|
523
|
+
into.require(task.channel === context.channel, "task.channel", `${path}.channel`, `a ${context.channel} provider may not publish a ${String(task.channel)} task`);
|
|
524
|
+
if (task.reference !== undefined) {
|
|
525
|
+
into.filled(task.reference, "task.reference", `${path}.reference`, "a reference must not be empty when present");
|
|
526
|
+
}
|
|
527
|
+
if (task.contact !== undefined)
|
|
528
|
+
validateContactInto(task.contact, `${path}.contact`, into);
|
|
529
|
+
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
530
|
+
validateTaskAttributes(task.attributes, `${path}.attributes`, into);
|
|
531
|
+
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
|
|
532
|
+
const capabilities = task.capabilities;
|
|
533
|
+
if (!isPlainObject(capabilities)) {
|
|
534
|
+
into.add("task.capabilities.shape", `${path}.capabilities`, "a task needs a capabilities object");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const allowed = isChannel(context.channel) ? TASK_CAPABILITIES[context.channel] : TASK_CAPABILITIES.voice;
|
|
538
|
+
for (const [name, declared] of Object.entries(capabilities)) {
|
|
539
|
+
if (declared === undefined)
|
|
540
|
+
continue;
|
|
541
|
+
if (!into.require(allowed.includes(name), "task.capability.channel", `${path}.capabilities.${name}`, `a ${context.channel} task may not declare ${name}`))
|
|
542
|
+
continue;
|
|
543
|
+
switch (name) {
|
|
544
|
+
case "dispositions":
|
|
545
|
+
validateDispositions(declared, `${path}.capabilities.dispositions`, into);
|
|
546
|
+
break;
|
|
547
|
+
case "custom":
|
|
548
|
+
validateCustomCapabilities(declared, `${path}.capabilities.custom`, into);
|
|
549
|
+
break;
|
|
550
|
+
case "blindTransfer":
|
|
551
|
+
case "conference":
|
|
552
|
+
validateDestinationDirectory(declared, `${path}.capabilities.${name}`, into);
|
|
553
|
+
break;
|
|
554
|
+
default:
|
|
555
|
+
// Presence is the permission: the flag capabilities carry no payload, so anything but
|
|
556
|
+
// true is a value a host would have to interpret.
|
|
557
|
+
into.require(declared === true, "task.capability.value", `${path}.capabilities.${name}`, `${name} is declared by presence: send true or omit it`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
// Breaks, team, snapshot.
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
function validateImposedBreak(value, path, into) {
|
|
565
|
+
if (!isPlainObject(value)) {
|
|
566
|
+
into.add("break.imposed.shape", path, "an imposed break must be an object");
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
// `by` is required either way. Who put somebody off the floor survives whether or not the
|
|
570
|
+
// break ends on a clock -- an imposed break with no origin is a state the agent cannot
|
|
571
|
+
// reason about.
|
|
572
|
+
into.require(isUserId(value.by), "break.imposed.by", `${path}.by`, "an imposed break must say who placed it");
|
|
573
|
+
if (value.endsAutomatically === true) {
|
|
574
|
+
into.timestamp(value.endsAt, "break.imposed.endsAt", `${path}.endsAt`);
|
|
575
|
+
}
|
|
576
|
+
else if (value.endsAutomatically === false) {
|
|
577
|
+
into.require(value.endsAt === undefined, "break.imposed.endsAt.unexpected", `${path}.endsAt`, "a break that does not end automatically must not carry an end time");
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
into.add("break.imposed.endsAutomatically", `${path}.endsAutomatically`, "an imposed break must say whether it ends automatically");
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function validateBreakState(value, path, into) {
|
|
584
|
+
if (!isPlainObject(value)) {
|
|
585
|
+
into.add("break.shape", path, "break state must be an object");
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
into.oneOf(value.approval, BREAK_APPROVALS, "break.approval", `${path}.approval`);
|
|
589
|
+
into.require(typeof value.accepting === "boolean", "break.accepting", `${path}.accepting`, "accepting must be a boolean");
|
|
590
|
+
for (const field of ["requestId", "refusedReason", "decisionReason"]) {
|
|
591
|
+
if (value[field] !== undefined) {
|
|
592
|
+
into.filled(value[field], `break.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (value.retryAfterMs !== undefined) {
|
|
596
|
+
into.require(typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) && value.retryAfterMs >= 0, "break.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
|
|
597
|
+
}
|
|
598
|
+
if (value.activeReasonId !== undefined) {
|
|
599
|
+
into.filled(value.activeReasonId, "break.activeReasonId", `${path}.activeReasonId`, "activeReasonId must not be empty when present");
|
|
600
|
+
// A break nobody is on has no reason. Reporting one beside `not-requested` describes a
|
|
601
|
+
// break that is not happening.
|
|
602
|
+
into.require(value.approval !== "not-requested", "break.activeReasonId.approval", `${path}.activeReasonId`, "activeReasonId must be omitted when no break is requested or in effect");
|
|
603
|
+
}
|
|
604
|
+
if (value.imposed !== undefined)
|
|
605
|
+
validateImposedBreak(value.imposed, `${path}.imposed`, into);
|
|
606
|
+
if (value.reasons === undefined)
|
|
607
|
+
return;
|
|
608
|
+
if (!Array.isArray(value.reasons)) {
|
|
609
|
+
into.add("break.reasons.shape", `${path}.reasons`, "break reasons must be an array when present");
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const seen = new Set();
|
|
613
|
+
value.reasons.forEach((reason, index) => {
|
|
614
|
+
const at = `${path}.reasons[${index}]`;
|
|
615
|
+
if (!isPlainObject(reason)) {
|
|
616
|
+
into.add("break.reason.shape", at, "each break reason must be an object");
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (into.filled(reason.id, "break.reason.id", `${at}.id`, "a break reason needs an id")) {
|
|
620
|
+
if (seen.has(reason.id))
|
|
621
|
+
into.add("break.reason.unique", `${at}.id`, `duplicate break reason id: ${reason.id}`);
|
|
622
|
+
seen.add(reason.id);
|
|
623
|
+
}
|
|
624
|
+
into.filled(reason.label, "break.reason.label", `${at}.label`, "a break reason needs a label");
|
|
625
|
+
if (reason.kind !== undefined)
|
|
626
|
+
into.oneOf(reason.kind, BREAK_KINDS, "break.reason.kind", `${at}.kind`);
|
|
627
|
+
if (reason.alwaysAvailable !== undefined) {
|
|
628
|
+
into.require(reason.alwaysAvailable === true, "break.reason.alwaysAvailable", `${at}.alwaysAvailable`, "alwaysAvailable is declared by presence: send true or omit it");
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
export function validateTeamRoster(roster, path = "team") {
|
|
633
|
+
const into = new Collector();
|
|
634
|
+
validateTeamRosterInto(roster, path, into);
|
|
635
|
+
return into.violations;
|
|
636
|
+
}
|
|
637
|
+
function validateTeamRosterInto(roster, path, into) {
|
|
638
|
+
if (!isPlainObject(roster)) {
|
|
639
|
+
into.add("team.shape", path, "a team roster must be an object");
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (roster.breakControl !== undefined) {
|
|
643
|
+
into.require(roster.breakControl === true, "team.breakControl", `${path}.breakControl`, "breakControl is declared by presence: send true or omit it");
|
|
644
|
+
}
|
|
645
|
+
if (!Array.isArray(roster.members)) {
|
|
646
|
+
into.add("team.members.shape", `${path}.members`, "a roster must carry a members array");
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const seen = new Set();
|
|
650
|
+
roster.members.forEach((member, index) => {
|
|
651
|
+
const at = `${path}.members[${index}]`;
|
|
652
|
+
if (!isPlainObject(member)) {
|
|
653
|
+
into.add("team.member.shape", at, "each roster member must be an object");
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (into.require(isUserId(member.id), "team.member.id", `${at}.id`, "a roster member needs a user id")) {
|
|
657
|
+
if (seen.has(member.id))
|
|
658
|
+
into.add("team.member.unique", `${at}.id`, `duplicate roster member: ${member.id}`);
|
|
659
|
+
seen.add(member.id);
|
|
660
|
+
}
|
|
661
|
+
into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
|
|
662
|
+
if (member.since !== undefined)
|
|
663
|
+
into.timestamp(member.since, "team.member.since", `${at}.since`);
|
|
664
|
+
if (member.break !== undefined)
|
|
665
|
+
into.oneOf(member.break, BREAK_APPROVALS, "team.member.break", `${at}.break`);
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
export function validateSnapshot(snapshot, manifest, path = "snapshot") {
|
|
669
|
+
const into = new Collector();
|
|
670
|
+
if (!isPlainObject(snapshot)) {
|
|
671
|
+
into.add("snapshot.shape", path, "a snapshot must be an object");
|
|
672
|
+
return into.violations;
|
|
673
|
+
}
|
|
674
|
+
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
675
|
+
into.oneOf(snapshot.status, CONNECTION_STATUSES, "snapshot.status", `${path}.status`);
|
|
676
|
+
into.filled(snapshot.sessionId, "snapshot.sessionId", `${path}.sessionId`, "a snapshot needs the session id it belongs to");
|
|
677
|
+
const sessionCapabilities = snapshot.sessionCapabilities;
|
|
678
|
+
if (!isPlainObject(sessionCapabilities)) {
|
|
679
|
+
into.add("snapshot.sessionCapabilities.shape", `${path}.sessionCapabilities`, "a snapshot needs a sessionCapabilities object");
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
for (const [name, declared] of Object.entries(sessionCapabilities)) {
|
|
683
|
+
if (declared === undefined)
|
|
684
|
+
continue;
|
|
685
|
+
if (!into.require(SESSION_CAPABILITIES.includes(name), "snapshot.sessionCapability.unknown", `${path}.sessionCapabilities.${name}`, `unsupported session capability: ${name}`))
|
|
686
|
+
continue;
|
|
687
|
+
into.require(declared === true, "snapshot.sessionCapability.value", `${path}.sessionCapabilities.${name}`, `${name} is declared by presence: send true or omit it`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
validateBreakState(snapshot.break, `${path}.break`, into);
|
|
691
|
+
if (!Array.isArray(snapshot.tasks)) {
|
|
692
|
+
into.add("snapshot.tasks.shape", `${path}.tasks`, "a snapshot must carry a tasks array");
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
const seen = new Set();
|
|
696
|
+
snapshot.tasks.forEach((task, index) => {
|
|
697
|
+
validateTaskInto(task, { channel }, `${path}.tasks[${index}]`, into);
|
|
698
|
+
if (isPlainObject(task) && isTaskId(task.id)) {
|
|
699
|
+
if (seen.has(task.id))
|
|
700
|
+
into.add("task.id.unique", `${path}.tasks[${index}].id`, `duplicate task id: ${task.id}`);
|
|
701
|
+
seen.add(task.id);
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
// Presence is the permission, and it cuts both ways: data a provider never declared a
|
|
706
|
+
// capability for is data Omni would show against a control the agent does not have.
|
|
707
|
+
const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
|
|
708
|
+
if (snapshot.contacts !== undefined) {
|
|
709
|
+
into.require(idle.contacts === true, "snapshot.contacts.capability", `${path}.contacts`, "contacts require the contacts idle capability");
|
|
710
|
+
if (Array.isArray(snapshot.contacts)) {
|
|
711
|
+
snapshot.contacts.forEach((contact, index) => validateContactInto(contact, `${path}.contacts[${index}]`, into));
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
into.add("snapshot.contacts.shape", `${path}.contacts`, "contacts must be an array when present");
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (snapshot.scheduledActivities !== undefined) {
|
|
718
|
+
into.require(idle.calendar === true, "snapshot.calendar.capability", `${path}.scheduledActivities`, "scheduled activities require the calendar idle capability");
|
|
719
|
+
if (Array.isArray(snapshot.scheduledActivities)) {
|
|
720
|
+
const seen = new Set();
|
|
721
|
+
snapshot.scheduledActivities.forEach((activity, index) => {
|
|
722
|
+
validateScheduledActivityInto(activity, `${path}.scheduledActivities[${index}]`, into);
|
|
723
|
+
if (isPlainObject(activity) && isFilled(activity.id)) {
|
|
724
|
+
if (seen.has(activity.id)) {
|
|
725
|
+
into.add("activity.id.unique", `${path}.scheduledActivities[${index}].id`, `duplicate activity id: ${activity.id}`);
|
|
726
|
+
}
|
|
727
|
+
seen.add(activity.id);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
into.add("snapshot.calendar.shape", `${path}.scheduledActivities`, "scheduledActivities must be an array when present");
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (snapshot.team !== undefined)
|
|
736
|
+
validateTeamRosterInto(snapshot.team, `${path}.team`, into);
|
|
737
|
+
return into.violations;
|
|
738
|
+
}
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
// Events.
|
|
741
|
+
// ---------------------------------------------------------------------------
|
|
742
|
+
function validateTaskOutcome(value, path, into) {
|
|
743
|
+
if (!isPlainObject(value)) {
|
|
744
|
+
into.add("event.taskEnded.outcome.shape", path, "an outcome must be an object");
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
switch (value.type) {
|
|
748
|
+
case "completed":
|
|
749
|
+
into.oneOf(value.by, COMPLETED_BY, "event.taskEnded.outcome.completed", `${path}.by`);
|
|
750
|
+
break;
|
|
751
|
+
case "transferred":
|
|
752
|
+
if (value.destination !== undefined) {
|
|
753
|
+
into.filled(value.destination, "event.taskEnded.outcome.transferred", `${path}.destination`, "a destination must not be empty when present");
|
|
754
|
+
}
|
|
755
|
+
break;
|
|
756
|
+
case "cancelled":
|
|
757
|
+
if (value.reason !== undefined) {
|
|
758
|
+
into.filled(value.reason, "event.taskEnded.outcome.cancelled", `${path}.reason`, "a reason must not be empty when present");
|
|
759
|
+
}
|
|
760
|
+
break;
|
|
761
|
+
case "expired":
|
|
762
|
+
// Only the phases in which a task is still waiting on somebody can expire.
|
|
763
|
+
into.oneOf(value.phase, EXPIRABLE_PHASES, "event.taskEnded.outcome.expired", `${path}.phase`);
|
|
764
|
+
break;
|
|
765
|
+
case "failed":
|
|
766
|
+
if (!isPlainObject(value.failure)) {
|
|
767
|
+
into.add("event.taskEnded.outcome.failed", `${path}.failure`, "a failed outcome must carry a failure");
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
into.filled(value.failure.code, "failure.code", `${path}.failure.code`, "a failure needs a code");
|
|
771
|
+
into.filled(value.failure.message, "failure.message", `${path}.failure.message`, "a failure needs a message");
|
|
772
|
+
into.require(typeof value.failure.retryable === "boolean", "failure.retryable", `${path}.failure.retryable`, "a failure must say whether it is retryable");
|
|
773
|
+
}
|
|
774
|
+
break;
|
|
775
|
+
default:
|
|
776
|
+
into.add("event.taskEnded.outcome.type", `${path}.type`, `unsupported outcome: ${String(value.type)}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
function validateProviderSummary(value, path, into) {
|
|
780
|
+
if (!isPlainObject(value)) {
|
|
781
|
+
into.add("event.summary.shape", path, "a provider summary must be an object");
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
into.filled(value.title, "event.summary.title", `${path}.title`, "a summary needs a title");
|
|
785
|
+
if (value.subtitle !== undefined) {
|
|
786
|
+
into.filled(value.subtitle, "event.summary.subtitle", `${path}.subtitle`, "a subtitle must not be empty when present");
|
|
787
|
+
}
|
|
788
|
+
into.require(typeof value.waitingCount === "number" && Number.isInteger(value.waitingCount) && value.waitingCount >= 0, "event.summary.waitingCount", `${path}.waitingCount`, "waitingCount must be a whole number, zero or more");
|
|
789
|
+
into.timestamp(value.updatedAt, "event.summary.updatedAt", `${path}.updatedAt`);
|
|
790
|
+
if (value.metrics === undefined)
|
|
791
|
+
return;
|
|
792
|
+
if (!Array.isArray(value.metrics)) {
|
|
793
|
+
into.add("event.summary.metrics.shape", `${path}.metrics`, "metrics must be an array when present");
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
value.metrics.forEach((metric, index) => {
|
|
797
|
+
const at = `${path}.metrics[${index}]`;
|
|
798
|
+
if (!isPlainObject(metric)) {
|
|
799
|
+
into.add("event.summary.metric.shape", at, "each metric must be an object");
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
into.filled(metric.id, "event.summary.metric.id", `${at}.id`, "a metric needs an id");
|
|
803
|
+
into.filled(metric.label, "event.summary.metric.label", `${at}.label`, "a metric needs a label");
|
|
804
|
+
into.require(typeof metric.value === "string", "event.summary.metric.value", `${at}.value`, "a metric value must be a string; the provider decides how it reads");
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
export function validateEventEnvelope(envelope, manifest, path = "event") {
|
|
808
|
+
const into = new Collector();
|
|
809
|
+
if (!isPlainObject(envelope)) {
|
|
810
|
+
into.add("event.shape", path, "an event envelope must be an object");
|
|
811
|
+
return into.violations;
|
|
812
|
+
}
|
|
813
|
+
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
814
|
+
into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
|
|
815
|
+
into.filled(envelope.sessionId, "event.sessionId", `${path}.sessionId`, "an event needs the session id it belongs to");
|
|
816
|
+
into.timestamp(envelope.occurredAt, "event.occurredAt", `${path}.occurredAt`);
|
|
817
|
+
const event = envelope.event;
|
|
818
|
+
if (!isPlainObject(event)) {
|
|
819
|
+
into.add("event.payload.shape", `${path}.event`, "an envelope must carry an event");
|
|
820
|
+
return into.violations;
|
|
821
|
+
}
|
|
822
|
+
const at = `${path}.event`;
|
|
823
|
+
switch (event.type) {
|
|
824
|
+
case "snapshot":
|
|
825
|
+
into.oneOf(event.reason, SNAPSHOT_REASONS, "event.snapshot.reason", `${at}.reason`);
|
|
826
|
+
into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`));
|
|
827
|
+
break;
|
|
828
|
+
case "provider-status":
|
|
829
|
+
into.oneOf(event.status, CONNECTION_STATUSES, "event.providerStatus.status", `${at}.status`);
|
|
830
|
+
if (event.message !== undefined) {
|
|
831
|
+
into.filled(event.message, "event.providerStatus.message", `${at}.message`, "a message must not be empty when present");
|
|
832
|
+
}
|
|
833
|
+
break;
|
|
834
|
+
case "break-state":
|
|
835
|
+
validateBreakState(event.break, `${at}.break`, into);
|
|
836
|
+
break;
|
|
837
|
+
case "task-offered":
|
|
838
|
+
validateTaskInto(event.task, { channel }, `${at}.task`, into);
|
|
839
|
+
if (event.acceptanceMode !== undefined) {
|
|
840
|
+
into.oneOf(event.acceptanceMode, ACCEPTANCE_MODES, "event.taskOffered.acceptanceMode", `${at}.acceptanceMode`);
|
|
841
|
+
}
|
|
842
|
+
for (const field of ["allocationExpiresAt", "preparationEndsAt"]) {
|
|
843
|
+
if (event[field] !== undefined)
|
|
844
|
+
into.timestamp(event[field], `event.taskOffered.${field}`, `${at}.${field}`);
|
|
845
|
+
}
|
|
846
|
+
break;
|
|
847
|
+
case "task-updated":
|
|
848
|
+
validateTaskInto(event.task, { channel }, `${at}.task`, into);
|
|
849
|
+
break;
|
|
850
|
+
case "task-media-ended":
|
|
851
|
+
into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
852
|
+
break;
|
|
853
|
+
case "task-ended":
|
|
854
|
+
into.require(isTaskId(event.taskId), "event.taskEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
855
|
+
validateTaskOutcome(event.outcome, `${at}.outcome`, into);
|
|
856
|
+
break;
|
|
857
|
+
case "announcement":
|
|
858
|
+
into.filled(event.text, "event.announcement.text", `${at}.text`, "an announcement needs text");
|
|
859
|
+
into.timestamp(event.announcedAt, "event.announcement.announcedAt", `${at}.announcedAt`);
|
|
860
|
+
if (event.expiresAt !== undefined)
|
|
861
|
+
into.timestamp(event.expiresAt, "event.announcement.expiresAt", `${at}.expiresAt`);
|
|
862
|
+
if (event.html !== undefined) {
|
|
863
|
+
into.require(typeof event.html === "string", "event.announcement.html", `${at}.html`, "html must be a string when present");
|
|
864
|
+
}
|
|
865
|
+
break;
|
|
866
|
+
case "provider-summary":
|
|
867
|
+
validateProviderSummary(event.summary, `${at}.summary`, into);
|
|
868
|
+
break;
|
|
869
|
+
case "team-updated":
|
|
870
|
+
validateTeamRosterInto(event.team, `${at}.team`, into);
|
|
871
|
+
break;
|
|
872
|
+
case "contacts-updated":
|
|
873
|
+
if (!Array.isArray(event.contacts)) {
|
|
874
|
+
into.add("event.contacts.shape", `${at}.contacts`, "contacts must be an array");
|
|
875
|
+
}
|
|
876
|
+
else {
|
|
877
|
+
event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into));
|
|
878
|
+
}
|
|
879
|
+
break;
|
|
880
|
+
case "calendar-updated":
|
|
881
|
+
if (!Array.isArray(event.scheduledActivities)) {
|
|
882
|
+
into.add("event.calendar.shape", `${at}.scheduledActivities`, "scheduledActivities must be an array");
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
event.scheduledActivities.forEach((activity, index) => validateScheduledActivityInto(activity, `${at}.scheduledActivities[${index}]`, into));
|
|
886
|
+
}
|
|
887
|
+
break;
|
|
888
|
+
default:
|
|
889
|
+
into.add("event.type", `${at}.type`, `unsupported event type: ${String(event.type)}`);
|
|
890
|
+
}
|
|
891
|
+
return into.violations;
|
|
892
|
+
}
|
|
893
|
+
// ---------------------------------------------------------------------------
|
|
894
|
+
// Authentication.
|
|
895
|
+
// ---------------------------------------------------------------------------
|
|
896
|
+
function validateUser(value, rule, path, into) {
|
|
897
|
+
if (!isPlainObject(value)) {
|
|
898
|
+
into.add(rule, path, "an identity must be an object");
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
into.require(isUserId(value.id), `${rule}.id`, `${path}.id`, "an identity needs a provider-issued user id");
|
|
902
|
+
into.filled(value.displayName, `${rule}.displayName`, `${path}.displayName`, "an identity needs a display name");
|
|
903
|
+
}
|
|
904
|
+
export function validateAuthenticationState(state, path = "authentication") {
|
|
905
|
+
const into = new Collector();
|
|
906
|
+
if (!isPlainObject(state)) {
|
|
907
|
+
into.add("authentication.shape", path, "an authentication state must be an object");
|
|
908
|
+
return into.violations;
|
|
909
|
+
}
|
|
910
|
+
if (!into.oneOf(state.status, AUTHENTICATION_STATUSES, "authentication.status", `${path}.status`)) {
|
|
911
|
+
return into.violations;
|
|
912
|
+
}
|
|
913
|
+
// Only `authenticated` may carry an expiry, and only the states that know who the agent is
|
|
914
|
+
// may carry an identity. Anything else is a state claiming knowledge it does not have.
|
|
915
|
+
if (state.status === "authenticated" || state.status === "refreshing") {
|
|
916
|
+
validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
|
|
917
|
+
}
|
|
918
|
+
else if (state.status === "expired") {
|
|
919
|
+
if (state.identity !== undefined)
|
|
920
|
+
validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
|
|
921
|
+
if (state.failure !== undefined) {
|
|
922
|
+
if (!isPlainObject(state.failure)) {
|
|
923
|
+
into.add("authentication.failure.shape", `${path}.failure`, "a failure must be an object when present");
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
into.filled(state.failure.code, "authentication.failure.code", `${path}.failure.code`, "a failure needs a code");
|
|
927
|
+
into.filled(state.failure.message, "authentication.failure.message", `${path}.failure.message`, "a failure needs a message");
|
|
928
|
+
into.require(typeof state.failure.retryable === "boolean", "authentication.failure.retryable", `${path}.failure.retryable`, "a failure must say whether it is retryable");
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
into.require(state.identity === undefined, "authentication.identity.unexpected", `${path}.identity`, `${state.status} must not carry an identity`);
|
|
934
|
+
}
|
|
935
|
+
if (state.expiresAt !== undefined) {
|
|
936
|
+
into.require(state.status === "authenticated", "authentication.expiresAt.unexpected", `${path}.expiresAt`, "only an authenticated state may carry an expiry");
|
|
937
|
+
into.timestamp(state.expiresAt, "authentication.expiresAt", `${path}.expiresAt`);
|
|
938
|
+
}
|
|
939
|
+
return into.violations;
|
|
940
|
+
}
|