@xema/omni-protocol 0.1.74 → 0.1.75
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/dist/testing.d.ts +7 -0
- package/dist/testing.js +17 -0
- package/dist/validation.d.ts +12 -0
- package/dist/validation.js +53 -3
- package/guide.md +9 -2
- package/package.json +1 -1
package/dist/testing.d.ts
CHANGED
|
@@ -57,6 +57,13 @@ export interface ExerciseAdapterOptions {
|
|
|
57
57
|
* memory has nothing, and is named. Without it the store is required and never read.
|
|
58
58
|
*/
|
|
59
59
|
rebuild?: () => Adapter<Channel>;
|
|
60
|
+
/**
|
|
61
|
+
* The values the queue locks on this login's tasks -- a party's number or email -- as the run's
|
|
62
|
+
* author knows them. A task whose party stands locked may carry none of them anywhere else
|
|
63
|
+
* (`task.locked.leak`). Required once any task locks a party field: a run that cannot ask the
|
|
64
|
+
* question is not a pass, and says so by throwing rather than passing.
|
|
65
|
+
*/
|
|
66
|
+
lockedValues?: readonly string[];
|
|
60
67
|
}
|
|
61
68
|
/**
|
|
62
69
|
* Adapter conformance exercise: validates the manifest, opens an authenticated session,
|
package/dist/testing.js
CHANGED
|
@@ -163,6 +163,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
163
163
|
const breaks = new BreakStream();
|
|
164
164
|
let seeded = false;
|
|
165
165
|
const duringRead = [];
|
|
166
|
+
let lockedPartySeen = false;
|
|
166
167
|
const storedSecrets = new Map();
|
|
167
168
|
const authenticationSecrets = {
|
|
168
169
|
get: async (key) => storedSecrets.get(key),
|
|
@@ -204,6 +205,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
204
205
|
capabilities: current().capabilities,
|
|
205
206
|
loginId: context.loginId,
|
|
206
207
|
autoAcceptTasks: context.autoAcceptTasks,
|
|
208
|
+
locked: options.lockedValues,
|
|
207
209
|
});
|
|
208
210
|
// The optional methods are optional only until something declares a need for them. Each
|
|
209
211
|
// check pairs a method with the declaration that requires it, as the guide's Live-connection
|
|
@@ -341,6 +343,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
341
343
|
message: `the provider reported a diagnostic: expected ${String(envelope.event.expected)}; observed ${String(envelope.event.observed)}` });
|
|
342
344
|
}
|
|
343
345
|
violations.push(...undeterminedTasks(eventTasks(envelope), "event"));
|
|
346
|
+
lockedPartySeen ||= eventTasks(envelope).some(locksParty);
|
|
344
347
|
if (eventNamesUsers(envelope))
|
|
345
348
|
requireMethod(live, "describeUsers", "an event publishes a UserId");
|
|
346
349
|
// Work is pulled, never pushed: an offer before the host stated capacity is an allocation
|
|
@@ -427,6 +430,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
427
430
|
tellRefused({ artefact: "snapshot", violations: broken });
|
|
428
431
|
}
|
|
429
432
|
violations.push(...undeterminedTasks(Array.isArray(snapshot?.tasks) ? snapshot.tasks : [], "snapshot.tasks"));
|
|
433
|
+
lockedPartySeen ||= (Array.isArray(snapshot?.tasks) ? snapshot.tasks : []).some(locksParty);
|
|
430
434
|
stream.seed(snapshot);
|
|
431
435
|
breaks.seed(snapshot);
|
|
432
436
|
seeded = true;
|
|
@@ -587,6 +591,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
587
591
|
message: "an unsubscribe, disconnect(), or close() threw during shutdown",
|
|
588
592
|
});
|
|
589
593
|
}
|
|
594
|
+
// A task locked a party field and the run was not told the value: task.locked.leak was never
|
|
595
|
+
// asked, and a result that cannot say is not returned as one that passed.
|
|
596
|
+
if (lockedPartySeen && options.lockedValues === undefined) {
|
|
597
|
+
stopObserving();
|
|
598
|
+
throw new TypeError("a task locks its party's number or email and the run states no lockedValues: task.locked.leak cannot be evaluated; pass the values the queue locked in options.lockedValues");
|
|
599
|
+
}
|
|
590
600
|
if (!options.collectOnly)
|
|
591
601
|
assertNoViolations(violations);
|
|
592
602
|
stopObserving();
|
|
@@ -727,6 +737,13 @@ export function assertAuthenticationRestoreAndExpiry(states) {
|
|
|
727
737
|
}
|
|
728
738
|
}
|
|
729
739
|
/** The tasks an envelope carries: the one a task event names, or a snapshot event's list. */
|
|
740
|
+
/** Whether a task's party stands locked on its number or email, which is what obliges a run to state the locked values. */
|
|
741
|
+
function locksParty(task) {
|
|
742
|
+
if (!isRecord(task) || !isRecord(task.party))
|
|
743
|
+
return false;
|
|
744
|
+
const locked = (value) => isRecord(value) && value.lockedBy !== undefined;
|
|
745
|
+
return locked(task.party.number) || locked(task.party.email);
|
|
746
|
+
}
|
|
730
747
|
function eventTasks(envelope) {
|
|
731
748
|
if (!isRecord(envelope) || !isRecord(envelope.event))
|
|
732
749
|
return [];
|
package/dist/validation.d.ts
CHANGED
|
@@ -32,6 +32,12 @@ export interface TaskValidationContext {
|
|
|
32
32
|
autoAcceptTasks?: boolean;
|
|
33
33
|
/** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
|
|
34
34
|
dialOutcomesDeclared?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* The values the queue locked on this login's tasks -- a party's number or email -- as whoever
|
|
37
|
+
* runs the validator knows them. Where a task's party stands locked, no other field of it may
|
|
38
|
+
* carry one of these. Unknown to a host, which never sees the value, and then unchecked.
|
|
39
|
+
*/
|
|
40
|
+
locked?: readonly string[];
|
|
35
41
|
}
|
|
36
42
|
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
37
43
|
/**
|
|
@@ -53,6 +59,12 @@ export interface ReaderContext {
|
|
|
53
59
|
levels?: readonly string[];
|
|
54
60
|
/** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
|
|
55
61
|
loginId?: string;
|
|
62
|
+
/**
|
|
63
|
+
* The values the queue locked on this login's tasks, as whoever runs the validator knows them:
|
|
64
|
+
* a conformance run states them, a host never has them. Where a task's party stands locked, no
|
|
65
|
+
* other field of it may carry one (`task.locked.leak`). Absent, unchecked.
|
|
66
|
+
*/
|
|
67
|
+
locked?: readonly string[];
|
|
56
68
|
/** `ConnectContext.autoAcceptTasks` as sent: whether a pending task states its `acceptance`. Unknown to a caller without the context, and then unchecked. */
|
|
57
69
|
autoAcceptTasks?: boolean;
|
|
58
70
|
}
|
package/dist/validation.js
CHANGED
|
@@ -598,6 +598,53 @@ const PERSON_SETTABLE = /^(hold|skill:.+)$/;
|
|
|
598
598
|
const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
|
|
599
599
|
/** The level ids in force: the manifest's, or the defaults when the caller holds no manifest. */
|
|
600
600
|
const levelIds = (levels) => levels ?? DEFAULT_LEVEL_IDS;
|
|
601
|
+
/**
|
|
602
|
+
* What the queue locks is locked on the whole task. Given the locked values, every other field an
|
|
603
|
+
* agent reads -- the title, the reference, an attribute, a custom control's label, a browser URL the
|
|
604
|
+
* desk shows -- is held to carrying none of them. A number is compared by its digits, so no
|
|
605
|
+
* formatting hides it; anything else by its text, case aside.
|
|
606
|
+
*/
|
|
607
|
+
function validateNothingLeaksInto(task, locked, path, into) {
|
|
608
|
+
ruleEvaluated("task.locked.leak");
|
|
609
|
+
const carries = (text, value) => {
|
|
610
|
+
if (typeof text !== "string")
|
|
611
|
+
return false;
|
|
612
|
+
const digits = value.replace(/[\s+().-]/g, "");
|
|
613
|
+
if (/^\d+$/.test(digits))
|
|
614
|
+
return text.replace(/\D/g, "").includes(digits);
|
|
615
|
+
return text.toLowerCase().includes(value.trim().toLowerCase());
|
|
616
|
+
};
|
|
617
|
+
const hold = (text, at) => {
|
|
618
|
+
for (const value of locked) {
|
|
619
|
+
if (carries(text, value))
|
|
620
|
+
into.add("task.locked.leak", at, "carries a value the queue locked: what the queue locks is locked on the whole task, and appears nowhere the agent reads");
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
hold(task.title, `${path}.title`);
|
|
624
|
+
hold(task.reference, `${path}.reference`);
|
|
625
|
+
if (Array.isArray(task.attributes)) {
|
|
626
|
+
task.attributes.forEach((attribute, index) => {
|
|
627
|
+
if (!isPlainObject(attribute))
|
|
628
|
+
return;
|
|
629
|
+
hold(attribute.label, `${path}.attributes[${index}].label`);
|
|
630
|
+
hold(attribute.value, `${path}.attributes[${index}].value`);
|
|
631
|
+
if (isPlainObject(attribute.party)) {
|
|
632
|
+
hold(attribute.party.number, `${path}.attributes[${index}].party.number`);
|
|
633
|
+
hold(attribute.party.email, `${path}.attributes[${index}].party.email`);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
const custom = isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
|
|
638
|
+
if (Array.isArray(custom))
|
|
639
|
+
custom.forEach((control, index) => { if (isPlainObject(control))
|
|
640
|
+
hold(control.label, `${path}.capabilities.custom[${index}].label`); });
|
|
641
|
+
if (Array.isArray(task.browsers)) {
|
|
642
|
+
task.browsers.forEach((browser, index) => {
|
|
643
|
+
if (isPlainObject(browser) && browser.urlVisibility !== "hidden")
|
|
644
|
+
hold(browser.url, `${path}.browsers[${index}].url`);
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
601
648
|
/** `lockedBy`: a declared level other than `person`, who never locks their own value. */
|
|
602
649
|
function validateLockedByInto(value, rule, path, levels, into) {
|
|
603
650
|
if (!into.filled(value, rule, path, "lockedBy names the level that locked it"))
|
|
@@ -1015,6 +1062,9 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1015
1062
|
}
|
|
1016
1063
|
if (task.party !== undefined)
|
|
1017
1064
|
validateContactInto(task.party, `${path}.party`, into, context.levels);
|
|
1065
|
+
if (context.locked !== undefined && isPlainObject(task.party) && (isLocked(task.party.number) || isLocked(task.party.email))) {
|
|
1066
|
+
validateNothingLeaksInto(task, context.locked, path, into);
|
|
1067
|
+
}
|
|
1018
1068
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
1019
1069
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
|
|
1020
1070
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into, { phase: task.phase, media: task.media });
|
|
@@ -1308,7 +1358,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1308
1358
|
let assisting;
|
|
1309
1359
|
let monitoring;
|
|
1310
1360
|
snapshot.tasks.forEach((task, index) => {
|
|
1311
|
-
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${path}.tasks[${index}]`, into);
|
|
1361
|
+
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
|
|
1312
1362
|
// A lead assists one call at a time, and listens to one at a time.
|
|
1313
1363
|
if (isPlainObject(task) && task.assisting !== undefined) {
|
|
1314
1364
|
if (assisting !== undefined)
|
|
@@ -1520,7 +1570,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1520
1570
|
validateBreakState(event.break, `${at}.break`, into);
|
|
1521
1571
|
break;
|
|
1522
1572
|
case "task-offered":
|
|
1523
|
-
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
|
|
1573
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1524
1574
|
// An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
|
|
1525
1575
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1526
1576
|
into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
|
|
@@ -1529,7 +1579,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1529
1579
|
into.timestamp(event.allocationExpiresAt, "event.taskOffered.allocationExpiresAt", `${at}.allocationExpiresAt`);
|
|
1530
1580
|
break;
|
|
1531
1581
|
case "task-updated":
|
|
1532
|
-
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
|
|
1582
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1533
1583
|
break;
|
|
1534
1584
|
case "task-media-started":
|
|
1535
1585
|
into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
|
package/guide.md
CHANGED
|
@@ -831,7 +831,14 @@ lock, so nothing that can be locked — a directory, a number — may carry that
|
|
|
831
831
|
contact's number and email are the same, since each identifies a person: where the queue says
|
|
832
832
|
the agent may not see it, the provider sends `{ lockedBy }` in its place — the last digits or
|
|
833
833
|
nothing — and a CRM link carries a token, never the value with a flag the desk is asked to honour.
|
|
834
|
-
|
|
834
|
+
**What the queue locks is locked on the whole task.** A number the agent may not see appears
|
|
835
|
+
nowhere else they read — not in the title, the reference, an attribute, a custom control's label,
|
|
836
|
+
or a browser URL the desk shows; a browser whose URL must carry it says `urlVisibility: "hidden"`.
|
|
837
|
+
The adapter is the one that knows the value, so it is the one held to it: given the values the
|
|
838
|
+
queue locked, the validator refuses any other field carrying one, digits compared as digits so no
|
|
839
|
+
formatting hides them (`task.locked.leak`), and a conformance run whose tasks lock a party's number
|
|
840
|
+
or email states those values in `lockedValues`, since a run that cannot ask the question is not a
|
|
841
|
+
pass. A host never has the value and never asks. A name is not locked. What the queue provides rather than permits — browsers, dispositions, custom
|
|
835
842
|
controls — is content, and is never locked.
|
|
836
843
|
|
|
837
844
|
**A lead sets the team's policy from their roster.** A login that declares
|
|
@@ -4624,7 +4631,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
4624
4631
|
| Function | Validates |
|
|
4625
4632
|
| --- | --- |
|
|
4626
4633
|
| `validateManifest(manifest)` | Identity, protocol-version interoperability, authentication methods, and idle-capability shapes. |
|
|
4627
|
-
| `validateTask(task, { channel })` | Identity, channel agreement, phase, wrap allowance, capability shapes, custom controls, and browsers. |
|
|
4634
|
+
| `validateTask(task, { channel, locked? })` | Identity, channel agreement, phase, wrap allowance, capability shapes, custom controls, and browsers. Given `locked`, the values the queue locked, a task whose party stands locked carries none of them anywhere else (`task.locked.leak`). |
|
|
4628
4635
|
| `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, the stated `taskCount` reconciled against the tasks carried, and every task, contact, and activity, including idle-capability gating both ways: a contribution the manifest never declared is refused, and one it declares is required, `[]` included. |
|
|
4629
4636
|
| `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
|
|
4630
4637
|
| `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
|