@xema/omni-protocol 0.1.9 → 0.1.11
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 +18 -4
- package/dist/index.d.ts +32 -18
- package/dist/testing.d.ts +37 -5
- package/dist/testing.js +168 -29
- package/dist/validation.d.ts +20 -4
- package/dist/validation.js +65 -29
- package/guide.md +129 -65
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,11 +40,25 @@ assertNoViolations(violations);
|
|
|
40
40
|
A violation carries a stable `rule` id such as `task.browser.url.scheme`, the `path` it was found
|
|
41
41
|
at such as `snapshot.tasks[0].browsers[1].url`, and a `message`.
|
|
42
42
|
|
|
43
|
+
Some rules need more than the object in hand. A roster never carries the agent it is published to,
|
|
44
|
+
a lead's snapshot always carries one and nobody else's ever does — and a validator cannot know who
|
|
45
|
+
is reading, or what their login declares, from the snapshot alone. `validateTeamRoster`,
|
|
46
|
+
`validateSnapshot`, and `validateEventEnvelope` each take an optional final `{ self, capabilities }`
|
|
47
|
+
from the `authenticated` state; given them, they report `team.member.self`, `team.request.self`,
|
|
48
|
+
`team.required`, `team.unentitled`, `team.requests.capability`, and `team.requests.required`.
|
|
49
|
+
Without them those rules are not checked. `exerciseAdapter` always passes both.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const { identity, capabilities } = authenticated;
|
|
53
|
+
validateSnapshot(snapshot, manifest, "snapshot", { self: identity.id, capabilities });
|
|
54
|
+
```
|
|
55
|
+
|
|
43
56
|
## Conformance
|
|
44
57
|
|
|
45
58
|
`exerciseAdapter` validates the manifest, opens an authenticated session, connects, checks
|
|
46
|
-
required capability methods, subscribes, validates the snapshot
|
|
47
|
-
|
|
59
|
+
required capability methods, subscribes, validates the snapshot, every delivered event, and every
|
|
60
|
+
authentication state published during the run — against the latest login — states a capacity,
|
|
61
|
+
then unsubscribes and disconnects.
|
|
48
62
|
|
|
49
63
|
```ts
|
|
50
64
|
const result = await exerciseAdapter(adapter, context, { collectOnly: true });
|
|
@@ -52,8 +66,8 @@ expect(result.violations).toEqual([]);
|
|
|
52
66
|
expect(result.disconnectWasClean).toBe(true);
|
|
53
67
|
```
|
|
54
68
|
|
|
55
|
-
Run the contract scenarios beside it — authentication restore and expiry,
|
|
56
|
-
assignments, break denial and retry, wrap timeout, browser isolation.
|
|
69
|
+
Run the contract scenarios beside it — authentication restore and expiry, capability withdrawal,
|
|
70
|
+
reconnect with missed assignments, break denial and retry, wrap timeout, browser isolation.
|
|
57
71
|
|
|
58
72
|
> **Assert both directions.** Every helper rejects a violating input as well as accepting a
|
|
59
73
|
> conforming one. A suite that only asserts "this conforming case does not throw" passes unchanged
|
package/dist/index.d.ts
CHANGED
|
@@ -135,9 +135,28 @@ export interface AuthenticationFailure {
|
|
|
135
135
|
/** Names a declared credentials field when the failure belongs to one. */
|
|
136
136
|
field?: string;
|
|
137
137
|
}
|
|
138
|
+
/** What a lead may do with their team. Declared by presence. */
|
|
139
|
+
export interface TeamCapabilities {
|
|
140
|
+
/** This lead decides their team's breaks. Requires `executeTeamBreak`. */
|
|
141
|
+
breakControl?: true;
|
|
142
|
+
/** This lead may join a member's call on request. Requires `executeTeamConsult`. */
|
|
143
|
+
consultControl?: true;
|
|
144
|
+
}
|
|
138
145
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
146
|
+
* What this login may do, beyond any one task. It travels with the identity because it is part
|
|
147
|
+
* of who the agent is on this provider: the provider knows the roles and says so at sign-in.
|
|
148
|
+
* Current as of the latest `authenticated` state, never fixed for the login.
|
|
149
|
+
*/
|
|
150
|
+
export interface SessionCapabilities {
|
|
151
|
+
/** This login may request a break. Requires the four break methods. */
|
|
152
|
+
breaks?: true;
|
|
153
|
+
/** This login leads a team: a `TeamRoster` is published to it on every snapshot, `[]` included. */
|
|
154
|
+
team?: TeamCapabilities;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Only `authenticated` and `refreshing` know who the agent is and what they may do, and only
|
|
158
|
+
* `authenticated` has something to expire. A state carrying more than it knows is a state Omni
|
|
159
|
+
* would render as fact.
|
|
141
160
|
*/
|
|
142
161
|
export type AuthenticationState = {
|
|
143
162
|
status: "signed-out";
|
|
@@ -146,10 +165,12 @@ export type AuthenticationState = {
|
|
|
146
165
|
} | {
|
|
147
166
|
status: "authenticated";
|
|
148
167
|
identity: User;
|
|
168
|
+
capabilities: SessionCapabilities;
|
|
149
169
|
expiresAt?: IsoTimestamp;
|
|
150
170
|
} | {
|
|
151
171
|
status: "refreshing";
|
|
152
172
|
identity: User;
|
|
173
|
+
capabilities: SessionCapabilities;
|
|
153
174
|
} | {
|
|
154
175
|
status: "expired";
|
|
155
176
|
identity?: User;
|
|
@@ -199,6 +220,7 @@ export type CompleteAuthenticationRequest = {
|
|
|
199
220
|
export type CompleteAuthenticationResult = {
|
|
200
221
|
status: "authenticated";
|
|
201
222
|
identity: User;
|
|
223
|
+
capabilities: SessionCapabilities;
|
|
202
224
|
expiresAt?: IsoTimestamp;
|
|
203
225
|
} | {
|
|
204
226
|
status: "rejected";
|
|
@@ -705,10 +727,6 @@ export interface TeamMember {
|
|
|
705
727
|
since?: IsoTimestamp;
|
|
706
728
|
break?: BreakApproval;
|
|
707
729
|
}
|
|
708
|
-
/**
|
|
709
|
-
* Published only to an agent entitled to one. Its presence is the permission -- nothing else
|
|
710
|
-
* makes somebody a lead, and there is no separate flag to fall out of step with the data.
|
|
711
|
-
*/
|
|
712
730
|
/** A member asking this lead to join their call. */
|
|
713
731
|
export interface LeadRequest {
|
|
714
732
|
id: string;
|
|
@@ -717,12 +735,13 @@ export interface LeadRequest {
|
|
|
717
735
|
note?: string;
|
|
718
736
|
since: IsoTimestamp;
|
|
719
737
|
}
|
|
738
|
+
/**
|
|
739
|
+
* The login is the permission: published to a login that declares `capabilities.team`, on every
|
|
740
|
+
* snapshot, and to nobody else. What the lead may do with it is on the login too, not here.
|
|
741
|
+
*/
|
|
720
742
|
export interface TeamRoster {
|
|
721
743
|
members: TeamMember[];
|
|
722
|
-
|
|
723
|
-
/** Present when this lead may join a member's call on request. */
|
|
724
|
-
consultControl?: true;
|
|
725
|
-
/** Omitted when the lead may not be asked; `[]` when nobody is asking. */
|
|
744
|
+
/** Omitted when the login lacks `team.consultControl`; `[]` when nobody is asking. */
|
|
726
745
|
requests?: LeadRequest[];
|
|
727
746
|
}
|
|
728
747
|
export type TeamConsultCommand = {
|
|
@@ -777,15 +796,10 @@ export type OpenMediaResult = {
|
|
|
777
796
|
status: "unavailable";
|
|
778
797
|
failure: ProtocolFailure;
|
|
779
798
|
};
|
|
780
|
-
export interface SessionCapabilities {
|
|
781
|
-
breaks?: true;
|
|
782
|
-
teamBreakControl?: true;
|
|
783
|
-
}
|
|
784
799
|
/** The provider's complete state at one moment. It replaces what Omni holds; never a patch. */
|
|
785
800
|
export interface Snapshot<C extends Channel = Channel> {
|
|
786
801
|
status: ConnectionStatus;
|
|
787
802
|
sessionId: string;
|
|
788
|
-
sessionCapabilities: SessionCapabilities;
|
|
789
803
|
break: BreakState;
|
|
790
804
|
/** Every task currently owned by this agent for this provider. */
|
|
791
805
|
tasks: Task<C>[];
|
|
@@ -888,7 +902,7 @@ export interface Connection<C extends Channel = Channel> {
|
|
|
888
902
|
/** Required when the manifest declares `idleCapabilities.dial`. */
|
|
889
903
|
dial?(request: DialRequest): Promise<DialResult>;
|
|
890
904
|
/**
|
|
891
|
-
* The four break methods stand or fall together. Declaring `
|
|
905
|
+
* The four break methods stand or fall together. Declaring `capabilities.breaks` at login and
|
|
892
906
|
* implementing `requestBreak` without `commitBreak` leaves an agent granted a break that can
|
|
893
907
|
* never start, and the two-phase coordination has no way to report it.
|
|
894
908
|
*/
|
|
@@ -896,9 +910,9 @@ export interface Connection<C extends Channel = Channel> {
|
|
|
896
910
|
commitBreak?(): Promise<BreakCommitResult>;
|
|
897
911
|
cancelBreak?(): Promise<BreakCancelResult>;
|
|
898
912
|
endBreak?(): Promise<BreakEndResult>;
|
|
899
|
-
/** Required when the
|
|
913
|
+
/** Required when the login declares `capabilities.team.breakControl`. */
|
|
900
914
|
executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
|
|
901
|
-
/** Required when the
|
|
915
|
+
/** Required when the login declares `capabilities.team.consultControl`. */
|
|
902
916
|
executeTeamConsult?(request: TeamConsultCommandRequest): Promise<TeamCommandResult>;
|
|
903
917
|
/** Required of every voice adapter: all voice audio lands in Omni. */
|
|
904
918
|
openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext } from "./index.js";
|
|
1
|
+
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type Manifest } from "./index.js";
|
|
2
2
|
import { type ProtocolViolation } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation } from "./validation.js";
|
|
4
4
|
export interface AdapterContractResult {
|
|
5
5
|
events: ProviderEventEnvelope[];
|
|
6
|
+
/** The state the session was restored with at sign-in. */
|
|
6
7
|
authenticationState: AuthenticationState;
|
|
7
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* The latest login the session published during the run. Equal to `authenticationState` unless
|
|
10
|
+
* the adapter republished `authenticated` -- capabilities are current, not fixed.
|
|
11
|
+
*/
|
|
12
|
+
login: AuthenticationState;
|
|
13
|
+
/** True only when every unsubscribe, `disconnect()`, and `close()` settled without throwing. */
|
|
8
14
|
disconnectWasClean: boolean;
|
|
9
15
|
/** Every violation observed. Non-empty only when `collectOnly` suppressed the throw. */
|
|
10
16
|
violations: readonly ProtocolViolation[];
|
|
@@ -16,8 +22,9 @@ export interface ExerciseAdapterOptions {
|
|
|
16
22
|
/**
|
|
17
23
|
* Adapter conformance exercise: validates the manifest, opens an authenticated session,
|
|
18
24
|
* connects, checks that every method the declarations require is implemented, subscribes,
|
|
19
|
-
* validates the snapshot
|
|
20
|
-
*
|
|
25
|
+
* validates the snapshot, every delivered event, and every authentication state the session
|
|
26
|
+
* publishes during the run -- against the latest login, since capabilities are current, not
|
|
27
|
+
* fixed -- states a capacity, then unsubscribes and disconnects.
|
|
21
28
|
*
|
|
22
29
|
* Violations are collected rather than thrown from inside the adapter's own
|
|
23
30
|
* dispatch path. Throwing from a subscribe listener unwinds through the provider
|
|
@@ -25,8 +32,33 @@ export interface ExerciseAdapterOptions {
|
|
|
25
32
|
* asynchronous one — which would let a non-conforming async adapter pass.
|
|
26
33
|
*/
|
|
27
34
|
export declare function exerciseAdapter<C extends Channel>(adapter: Adapter<C>, context: ConnectContext, options?: ExerciseAdapterOptions): Promise<AdapterContractResult>;
|
|
28
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Validates restored authentication followed by a refresh failure or expiry. Every state is
|
|
37
|
+
* validated, and a `refreshing` state must carry over the login it refreshes.
|
|
38
|
+
*/
|
|
29
39
|
export declare function assertAuthenticationRestoreAndExpiry(states: readonly AuthenticationState[]): void;
|
|
40
|
+
/**
|
|
41
|
+
* Capabilities are current, not fixed. A provider that withdraws one republishes `authenticated`
|
|
42
|
+
* with the new set, and the next snapshot agrees with it. `states` is what the authentication
|
|
43
|
+
* session published, first to last: beginning and ending `authenticated` for the same identity,
|
|
44
|
+
* passing only through usable states (`expired` or `signed-out` ends the login instead), with at
|
|
45
|
+
* least one capability gone by the end. `snapshot` is the first snapshot published after the last
|
|
46
|
+
* state, validated against that login -- so a roster still published to a login that no longer
|
|
47
|
+
* leads, or requests to one that may no longer join, is the failure.
|
|
48
|
+
*/
|
|
49
|
+
export declare function assertCapabilityWithdrawal(states: readonly AuthenticationState[], snapshot: Snapshot, manifest: Manifest): void;
|
|
50
|
+
/**
|
|
51
|
+
* A command that arrives after its capability was withdrawn is answered `failed` with
|
|
52
|
+
* `omni.capability-not-enabled`: the provider names it, so Omni never has to infer from a
|
|
53
|
+
* capability change it may not have rendered yet that "no longer a lead" is the message.
|
|
54
|
+
* Takes any command result -- a team command, a break method -- after such a withdrawal.
|
|
55
|
+
*/
|
|
56
|
+
export declare function assertCommandRefusedAfterWithdrawal(result: {
|
|
57
|
+
status: string;
|
|
58
|
+
failure?: {
|
|
59
|
+
code: string;
|
|
60
|
+
};
|
|
61
|
+
}): void;
|
|
30
62
|
/** Validates duplicate delivery and returns the event sequence Omni applies once per ID. */
|
|
31
63
|
export declare function assertDuplicateEventDelivery<C extends Channel>(envelopes: readonly ProviderEventEnvelope<C>[]): ProviderEventEnvelope<C>[];
|
|
32
64
|
/** Validates an authoritative reconnect snapshot containing assignments missed while offline. */
|
package/dist/testing.js
CHANGED
|
@@ -4,8 +4,9 @@ export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* Adapter conformance exercise: validates the manifest, opens an authenticated session,
|
|
6
6
|
* connects, checks that every method the declarations require is implemented, subscribes,
|
|
7
|
-
* validates the snapshot
|
|
8
|
-
*
|
|
7
|
+
* validates the snapshot, every delivered event, and every authentication state the session
|
|
8
|
+
* publishes during the run -- against the latest login, since capabilities are current, not
|
|
9
|
+
* fixed -- states a capacity, then unsubscribes and disconnects.
|
|
9
10
|
*
|
|
10
11
|
* Violations are collected rather than thrown from inside the adapter's own
|
|
11
12
|
* dispatch path. Throwing from a subscribe listener unwinds through the provider
|
|
@@ -26,7 +27,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
26
27
|
});
|
|
27
28
|
let connection;
|
|
28
29
|
let unsubscribe;
|
|
30
|
+
let unsubscribeAuthentication;
|
|
29
31
|
let authenticationState;
|
|
32
|
+
let login;
|
|
30
33
|
let disconnectWasClean = false;
|
|
31
34
|
try {
|
|
32
35
|
authenticationState = await authentication.state();
|
|
@@ -34,30 +37,74 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
34
37
|
if (authenticationState.status !== "authenticated") {
|
|
35
38
|
throw new Error(`Adapter contract exercise requires authenticated test state, received ${authenticationState.status}`);
|
|
36
39
|
}
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
// The harness knows who is signed in and what their login declares, so it can hold the
|
|
41
|
+
// adapter to rules the structural validators cannot check alone: a roster never carries the
|
|
42
|
+
// agent it is published to, a lead's snapshot always carries one, nobody else's ever does.
|
|
43
|
+
// Capabilities are current, not fixed, so the login is read when something is validated,
|
|
44
|
+
// never captured at sign-in: a provider that withdraws one republishes `authenticated`, and
|
|
45
|
+
// everything published after that is held to the new set. A published state that fails
|
|
46
|
+
// validation is reported and not adopted -- the harness keeps the last login it could trust.
|
|
47
|
+
login = authenticationState;
|
|
48
|
+
const current = () => {
|
|
49
|
+
if (login === undefined)
|
|
50
|
+
throw new Error("unreachable: the exercise has an authenticated login");
|
|
51
|
+
return login;
|
|
52
|
+
};
|
|
53
|
+
const reader = () => ({ self: current().identity.id, capabilities: current().capabilities });
|
|
39
54
|
// The optional methods are optional only until something declares a need for them. Each
|
|
40
55
|
// check pairs a method with the declaration that requires it, as the guide's Live-connection
|
|
41
56
|
// table does; a missing one is a control the agent would be shown and could never use.
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
57
|
+
const reported = new Set();
|
|
58
|
+
const requireMethod = (on, name, because) => {
|
|
59
|
+
if (typeof on[name] === "function" || reported.has(name))
|
|
60
|
+
return;
|
|
61
|
+
reported.add(name);
|
|
62
|
+
violations.push({
|
|
63
|
+
rule: `connection.${name}.required`,
|
|
64
|
+
path: `connection.${name}`,
|
|
65
|
+
message: `${because}, but the connection does not implement ${name}()`,
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
const requireCapabilityMethods = (on, capabilities) => {
|
|
69
|
+
if (capabilities.breaks === true) {
|
|
70
|
+
// The four stand or fall together: `granted` is a promise to honour a later commit, and
|
|
71
|
+
// an adapter with requestBreak but no commitBreak leaves an agent a break that never starts.
|
|
72
|
+
for (const method of ["requestBreak", "commitBreak", "cancelBreak", "endBreak"]) {
|
|
73
|
+
requireMethod(on, method, "the login declares capabilities.breaks");
|
|
74
|
+
}
|
|
49
75
|
}
|
|
76
|
+
if (capabilities.team?.breakControl === true)
|
|
77
|
+
requireMethod(on, "executeTeamBreak", "the login declares capabilities.team.breakControl");
|
|
78
|
+
if (capabilities.team?.consultControl === true)
|
|
79
|
+
requireMethod(on, "executeTeamConsult", "the login declares capabilities.team.consultControl");
|
|
50
80
|
};
|
|
81
|
+
unsubscribeAuthentication = authentication.subscribe(state => {
|
|
82
|
+
const own = validateAuthenticationState(state);
|
|
83
|
+
violations.push(...own);
|
|
84
|
+
if (own.length > 0)
|
|
85
|
+
return;
|
|
86
|
+
if (state.status === "refreshing") {
|
|
87
|
+
violations.push(...refreshingCarriesOver(current(), state, "authentication"));
|
|
88
|
+
}
|
|
89
|
+
else if (state.status === "authenticated") {
|
|
90
|
+
login = state;
|
|
91
|
+
// A capability granted later requires its methods just as one declared at sign-in does.
|
|
92
|
+
if (connection !== undefined)
|
|
93
|
+
requireCapabilityMethods(connection, state.capabilities);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
connection = await adapter.connect(context);
|
|
97
|
+
const live = connection;
|
|
51
98
|
// Dial is declared by presence: the capability object carries a destination policy rather
|
|
52
99
|
// than an `enabled` flag, so its presence is the declaration.
|
|
53
100
|
if (adapter.manifest.idleCapabilities?.dial !== undefined)
|
|
54
|
-
requireMethod("dial", "the manifest declares dial");
|
|
101
|
+
requireMethod(live, "dial", "the manifest declares dial");
|
|
55
102
|
// Every voice task's audio lands in Omni, so there is no voice adapter that does not open it.
|
|
56
103
|
if (adapter.manifest.channel === "voice")
|
|
57
|
-
requireMethod("openMedia", "the manifest channel is voice");
|
|
104
|
+
requireMethod(live, "openMedia", "the manifest channel is voice");
|
|
58
105
|
const eventIds = new Set();
|
|
59
106
|
unsubscribe = connection.subscribe(envelope => {
|
|
60
|
-
violations.push(...validateEventEnvelope(envelope, adapter.manifest));
|
|
107
|
+
violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
|
|
61
108
|
if (typeof envelope?.id === "string") {
|
|
62
109
|
if (eventIds.has(envelope.id))
|
|
63
110
|
return;
|
|
@@ -66,20 +113,10 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
66
113
|
events.push(envelope);
|
|
67
114
|
});
|
|
68
115
|
const snapshot = await connection.snapshot();
|
|
69
|
-
violations.push(...validateSnapshot(snapshot, adapter.manifest));
|
|
70
|
-
|
|
71
|
-
// The four stand or fall together: `granted` is a promise to honour a later commit, and
|
|
72
|
-
// an adapter with requestBreak but no commitBreak leaves an agent a break that never starts.
|
|
73
|
-
for (const method of ["requestBreak", "commitBreak", "cancelBreak", "endBreak"]) {
|
|
74
|
-
requireMethod(method, "the snapshot declares sessionCapabilities.breaks");
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (snapshot?.team?.breakControl === true)
|
|
78
|
-
requireMethod("executeTeamBreak", "the roster carries breakControl");
|
|
79
|
-
if (snapshot?.team?.consultControl === true)
|
|
80
|
-
requireMethod("executeTeamConsult", "the roster carries consultControl");
|
|
116
|
+
violations.push(...validateSnapshot(snapshot, adapter.manifest, "snapshot", reader()));
|
|
117
|
+
requireCapabilityMethods(live, current().capabilities);
|
|
81
118
|
if (publishesUserIds(snapshot))
|
|
82
|
-
requireMethod("describeUsers", "the snapshot publishes a UserId");
|
|
119
|
+
requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
|
|
83
120
|
// Capacity is stated, not requested: nothing may be allocated until it is, so a connection
|
|
84
121
|
// that will not accept one is a connection nothing can be given to.
|
|
85
122
|
const capacity = await connection.setCapacity({ count: 1 });
|
|
@@ -99,6 +136,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
99
136
|
catch {
|
|
100
137
|
clean = false;
|
|
101
138
|
}
|
|
139
|
+
try {
|
|
140
|
+
unsubscribeAuthentication?.();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
clean = false;
|
|
144
|
+
}
|
|
102
145
|
try {
|
|
103
146
|
await connection?.disconnect();
|
|
104
147
|
}
|
|
@@ -117,7 +160,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
117
160
|
violations.push({
|
|
118
161
|
rule: "connection.disconnect.clean",
|
|
119
162
|
path: "connection.disconnect",
|
|
120
|
-
message: "unsubscribe
|
|
163
|
+
message: "an unsubscribe, disconnect(), or close() threw during shutdown",
|
|
121
164
|
});
|
|
122
165
|
}
|
|
123
166
|
if (!options.collectOnly)
|
|
@@ -125,6 +168,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
125
168
|
return {
|
|
126
169
|
events: events,
|
|
127
170
|
authenticationState: authenticationState,
|
|
171
|
+
login: (login ?? authenticationState),
|
|
128
172
|
disconnectWasClean,
|
|
129
173
|
violations,
|
|
130
174
|
};
|
|
@@ -143,8 +187,55 @@ function publishesUserIds(snapshot) {
|
|
|
143
187
|
return false;
|
|
144
188
|
return snapshot.tasks.some(task => Array.isArray(task?.handlingHistory) && task.handlingHistory.some(step => step?.by !== undefined));
|
|
145
189
|
}
|
|
146
|
-
|
|
190
|
+
const sameCapabilities = (a, b) => a.breaks === b.breaks &&
|
|
191
|
+
(a.team === undefined) === (b.team === undefined) &&
|
|
192
|
+
a.team?.breakControl === b.team?.breakControl &&
|
|
193
|
+
a.team?.consultControl === b.team?.consultControl;
|
|
194
|
+
/**
|
|
195
|
+
* `refreshing` carries the identity and capabilities of the login it refreshes. A change to
|
|
196
|
+
* either is published as `authenticated`; a different identity is a new login.
|
|
197
|
+
*/
|
|
198
|
+
function refreshingCarriesOver(login, state, path) {
|
|
199
|
+
const found = [];
|
|
200
|
+
if (state.identity.id !== login.identity.id) {
|
|
201
|
+
found.push({
|
|
202
|
+
rule: "authentication.refreshing.identity",
|
|
203
|
+
path: `${path}.identity.id`,
|
|
204
|
+
message: "refreshing carries the identity of the login it refreshes; a different identity is a new login",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (!sameCapabilities(state.capabilities, login.capabilities)) {
|
|
208
|
+
found.push({
|
|
209
|
+
rule: "authentication.refreshing.capabilities",
|
|
210
|
+
path: `${path}.capabilities`,
|
|
211
|
+
message: "refreshing carries the capabilities of the login it refreshes; a change is published as authenticated",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return found;
|
|
215
|
+
}
|
|
216
|
+
/** Validates each state of a published sequence, and that every `refreshing` carries over the login before it. */
|
|
217
|
+
function assertLoginSequence(states, summary) {
|
|
218
|
+
const found = [];
|
|
219
|
+
let login;
|
|
220
|
+
states.forEach((state, index) => {
|
|
221
|
+
const path = `states[${index}]`;
|
|
222
|
+
const own = validateAuthenticationState(state, path);
|
|
223
|
+
found.push(...own);
|
|
224
|
+
if (own.length > 0)
|
|
225
|
+
return;
|
|
226
|
+
if (state.status === "authenticated")
|
|
227
|
+
login = state;
|
|
228
|
+
else if (state.status === "refreshing" && login !== undefined)
|
|
229
|
+
found.push(...refreshingCarriesOver(login, state, path));
|
|
230
|
+
});
|
|
231
|
+
assertNoViolations(found, summary);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Validates restored authentication followed by a refresh failure or expiry. Every state is
|
|
235
|
+
* validated, and a `refreshing` state must carry over the login it refreshes.
|
|
236
|
+
*/
|
|
147
237
|
export function assertAuthenticationRestoreAndExpiry(states) {
|
|
238
|
+
assertLoginSequence(states, "Authentication scenario");
|
|
148
239
|
if (states.length < 2 || states[0]?.status !== "authenticated") {
|
|
149
240
|
throw new Error("Authentication scenario must start with a restored authenticated state");
|
|
150
241
|
}
|
|
@@ -157,6 +248,54 @@ export function assertAuthenticationRestoreAndExpiry(states) {
|
|
|
157
248
|
throw new Error("Refreshing state must occur before expiry");
|
|
158
249
|
}
|
|
159
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* Capabilities are current, not fixed. A provider that withdraws one republishes `authenticated`
|
|
253
|
+
* with the new set, and the next snapshot agrees with it. `states` is what the authentication
|
|
254
|
+
* session published, first to last: beginning and ending `authenticated` for the same identity,
|
|
255
|
+
* passing only through usable states (`expired` or `signed-out` ends the login instead), with at
|
|
256
|
+
* least one capability gone by the end. `snapshot` is the first snapshot published after the last
|
|
257
|
+
* state, validated against that login -- so a roster still published to a login that no longer
|
|
258
|
+
* leads, or requests to one that may no longer join, is the failure.
|
|
259
|
+
*/
|
|
260
|
+
export function assertCapabilityWithdrawal(states, snapshot, manifest) {
|
|
261
|
+
assertLoginSequence(states, "Capability withdrawal");
|
|
262
|
+
const stray = states.findIndex(state => state.status !== "authenticated" && state.status !== "refreshing");
|
|
263
|
+
if (stray >= 0) {
|
|
264
|
+
throw new Error(`Capability withdrawal passes only through usable states; states[${stray}] is ${states[stray]?.status}, which ends the login`);
|
|
265
|
+
}
|
|
266
|
+
const first = states[0];
|
|
267
|
+
const last = states.at(-1);
|
|
268
|
+
if (first?.status !== "authenticated" || last?.status !== "authenticated") {
|
|
269
|
+
throw new Error("Capability withdrawal must begin and end with an authenticated login");
|
|
270
|
+
}
|
|
271
|
+
if (first.identity.id !== last.identity.id) {
|
|
272
|
+
throw new Error("Capability withdrawal must keep the identity: a different identity is a new login");
|
|
273
|
+
}
|
|
274
|
+
const before = first.capabilities;
|
|
275
|
+
const after = last.capabilities;
|
|
276
|
+
const withdrawn = (before.breaks === true && after.breaks !== true) ||
|
|
277
|
+
(before.team !== undefined && after.team === undefined) ||
|
|
278
|
+
(before.team?.breakControl === true && after.team?.breakControl !== true) ||
|
|
279
|
+
(before.team?.consultControl === true && after.team?.consultControl !== true);
|
|
280
|
+
if (!withdrawn) {
|
|
281
|
+
throw new Error("Capability withdrawal must end with at least one capability the first login declared withdrawn");
|
|
282
|
+
}
|
|
283
|
+
assertNoViolations(validateSnapshot(snapshot, manifest, "snapshot", { self: last.identity.id, capabilities: after }), "Capability withdrawal: the snapshot after the last login must agree with it");
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* A command that arrives after its capability was withdrawn is answered `failed` with
|
|
287
|
+
* `omni.capability-not-enabled`: the provider names it, so Omni never has to infer from a
|
|
288
|
+
* capability change it may not have rendered yet that "no longer a lead" is the message.
|
|
289
|
+
* Takes any command result -- a team command, a break method -- after such a withdrawal.
|
|
290
|
+
*/
|
|
291
|
+
export function assertCommandRefusedAfterWithdrawal(result) {
|
|
292
|
+
if (result.status !== "failed") {
|
|
293
|
+
throw new Error(`A command after its capability was withdrawn must fail; received ${result.status}`);
|
|
294
|
+
}
|
|
295
|
+
if (result.failure?.code !== "omni.capability-not-enabled") {
|
|
296
|
+
throw new Error(`A command after its capability was withdrawn fails with omni.capability-not-enabled, not ${result.failure?.code}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
160
299
|
/** Validates duplicate delivery and returns the event sequence Omni applies once per ID. */
|
|
161
300
|
export function assertDuplicateEventDelivery(envelopes) {
|
|
162
301
|
const byId = new Map();
|
package/dist/validation.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ProtocolViolation } from "./index.js";
|
|
1
|
+
import { type ProtocolViolation, type SessionCapabilities, type UserId } from "./index.js";
|
|
2
2
|
export type { ProtocolViolation } from "./index.js";
|
|
3
3
|
export declare class ProtocolConformanceError extends Error {
|
|
4
4
|
readonly violations: readonly ProtocolViolation[];
|
|
@@ -15,7 +15,23 @@ export interface TaskValidationContext {
|
|
|
15
15
|
channel: string;
|
|
16
16
|
}
|
|
17
17
|
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Who is reading what the adapter published, and what their login declares. The validators check
|
|
20
|
+
* structure without it; given it, they also hold what the adapter publishes to the login.
|
|
21
|
+
*/
|
|
22
|
+
export interface ReaderContext {
|
|
23
|
+
/**
|
|
24
|
+
* The signed-in agent, `AuthenticationState.identity.id`. A lead does not report to themself:
|
|
25
|
+
* a roster that lists them in `members`, or their own ask in `requests`, is a violation.
|
|
26
|
+
*/
|
|
27
|
+
self?: UserId;
|
|
28
|
+
/**
|
|
29
|
+
* The login's `AuthenticationState.capabilities`. The login is the permission: a lead's
|
|
30
|
+
* snapshot carries a roster, nobody else's does, and `requests` need `team.consultControl`.
|
|
31
|
+
*/
|
|
32
|
+
capabilities?: SessionCapabilities;
|
|
33
|
+
}
|
|
34
|
+
export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
35
|
+
export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
36
|
+
export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
21
37
|
export declare function validateAuthenticationState(state: unknown, path?: string): ProtocolViolation[];
|
package/dist/validation.js
CHANGED
|
@@ -69,7 +69,8 @@ const DIAL_DESTINATION_POLICIES = membersOf({ "contacts-only": true, "any-number
|
|
|
69
69
|
const SNAPSHOT_REASONS = membersOf({
|
|
70
70
|
reconnected: true, "provider-requested": true,
|
|
71
71
|
});
|
|
72
|
-
const SESSION_CAPABILITIES = membersOf({ breaks: true,
|
|
72
|
+
const SESSION_CAPABILITIES = membersOf({ breaks: true, team: true });
|
|
73
|
+
const TEAM_CAPABILITIES = membersOf({ breakControl: true, consultControl: true });
|
|
73
74
|
const COMPLETED_BY = membersOf({ agent: true, provider: true });
|
|
74
75
|
const EXPIRABLE_PHASES = membersOf({
|
|
75
76
|
pending: true, confirmed: true, preparing: true,
|
|
@@ -705,25 +706,34 @@ function validateBreakState(value, path, into) {
|
|
|
705
706
|
}
|
|
706
707
|
});
|
|
707
708
|
}
|
|
708
|
-
export function validateTeamRoster(roster, path = "team") {
|
|
709
|
+
export function validateTeamRoster(roster, path = "team", context = {}) {
|
|
709
710
|
const into = new Collector();
|
|
710
|
-
validateTeamRosterInto(roster, path, into);
|
|
711
|
+
validateTeamRosterInto(roster, path, context, into);
|
|
711
712
|
return into.violations;
|
|
712
713
|
}
|
|
713
|
-
function validateTeamRosterInto(roster, path, into) {
|
|
714
|
+
function validateTeamRosterInto(roster, path, context, into) {
|
|
714
715
|
if (!isPlainObject(roster)) {
|
|
715
716
|
into.add("team.shape", path, "a team roster must be an object");
|
|
716
717
|
return;
|
|
717
718
|
}
|
|
718
|
-
|
|
719
|
-
|
|
719
|
+
// The login is the permission: a roster reaches a login that declares `capabilities.team` and
|
|
720
|
+
// nobody else. Only a caller holding the login can check it.
|
|
721
|
+
if (context.capabilities !== undefined && context.capabilities.team === undefined) {
|
|
722
|
+
into.add("team.unentitled", path, "a roster published to a login that does not declare capabilities.team: the login is the permission");
|
|
720
723
|
}
|
|
721
|
-
if (roster.
|
|
722
|
-
|
|
724
|
+
if (roster.requests === undefined) {
|
|
725
|
+
// `[]` says nobody is asking; omission says the lead may not be asked. A login that may be
|
|
726
|
+
// asked therefore always carries the list.
|
|
727
|
+
if (context.capabilities?.team?.consultControl === true) {
|
|
728
|
+
into.add("team.requests.required", `${path}.requests`, "the login declares team.consultControl, so the roster carries requests: [] when nobody is asking");
|
|
729
|
+
}
|
|
723
730
|
}
|
|
724
|
-
|
|
731
|
+
else {
|
|
725
732
|
// Requests are what a lead acts on, so a lead who may not act has no business receiving them.
|
|
726
|
-
|
|
733
|
+
// Whether they may is on the login, so the check needs the login in hand.
|
|
734
|
+
if (context.capabilities !== undefined) {
|
|
735
|
+
into.require(context.capabilities.team?.consultControl === true, "team.requests.capability", `${path}.requests`, "requests require team.consultControl on the login: a lead who may not join has nothing to decide");
|
|
736
|
+
}
|
|
727
737
|
if (!Array.isArray(roster.requests)) {
|
|
728
738
|
into.add("team.requests.shape", `${path}.requests`, "requests must be an array when present");
|
|
729
739
|
}
|
|
@@ -740,7 +750,9 @@ function validateTeamRosterInto(roster, path, into) {
|
|
|
740
750
|
into.add("team.request.unique", `${at}.id`, `duplicate request id: ${request.id}`);
|
|
741
751
|
seenRequests.add(request.id);
|
|
742
752
|
}
|
|
743
|
-
into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking")
|
|
753
|
+
if (into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking")) {
|
|
754
|
+
into.require(request.memberId !== context.self, "team.request.self", `${at}.memberId`, "the roster carries the reader's own ask: an agent's request for a lead goes to whoever leads them");
|
|
755
|
+
}
|
|
744
756
|
into.require(isTaskId(request.taskId), "team.request.taskId", `${at}.taskId`, "a request names the task the lead would join");
|
|
745
757
|
if (request.note !== undefined)
|
|
746
758
|
into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
|
|
@@ -763,6 +775,7 @@ function validateTeamRosterInto(roster, path, into) {
|
|
|
763
775
|
if (seen.has(member.id))
|
|
764
776
|
into.add("team.member.unique", `${at}.id`, `duplicate roster member: ${member.id}`);
|
|
765
777
|
seen.add(member.id);
|
|
778
|
+
into.require(member.id !== context.self, "team.member.self", `${at}.id`, "the roster carries the agent it is published to: a lead does not report to themself");
|
|
766
779
|
}
|
|
767
780
|
into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
|
|
768
781
|
if (member.since !== undefined)
|
|
@@ -771,7 +784,7 @@ function validateTeamRosterInto(roster, path, into) {
|
|
|
771
784
|
into.oneOf(member.break, BREAK_APPROVALS, "team.member.break", `${at}.break`);
|
|
772
785
|
});
|
|
773
786
|
}
|
|
774
|
-
export function validateSnapshot(snapshot, manifest, path = "snapshot") {
|
|
787
|
+
export function validateSnapshot(snapshot, manifest, path = "snapshot", context = {}) {
|
|
775
788
|
const into = new Collector();
|
|
776
789
|
if (!isPlainObject(snapshot)) {
|
|
777
790
|
into.add("snapshot.shape", path, "a snapshot must be an object");
|
|
@@ -780,19 +793,6 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot") {
|
|
|
780
793
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
781
794
|
into.oneOf(snapshot.status, CONNECTION_STATUSES, "snapshot.status", `${path}.status`);
|
|
782
795
|
into.filled(snapshot.sessionId, "snapshot.sessionId", `${path}.sessionId`, "a snapshot needs the session id it belongs to");
|
|
783
|
-
const sessionCapabilities = snapshot.sessionCapabilities;
|
|
784
|
-
if (!isPlainObject(sessionCapabilities)) {
|
|
785
|
-
into.add("snapshot.sessionCapabilities.shape", `${path}.sessionCapabilities`, "a snapshot needs a sessionCapabilities object");
|
|
786
|
-
}
|
|
787
|
-
else {
|
|
788
|
-
for (const [name, declared] of Object.entries(sessionCapabilities)) {
|
|
789
|
-
if (declared === undefined)
|
|
790
|
-
continue;
|
|
791
|
-
if (!into.require(SESSION_CAPABILITIES.includes(name), "snapshot.sessionCapability.unknown", `${path}.sessionCapabilities.${name}`, `unsupported session capability: ${name}`))
|
|
792
|
-
continue;
|
|
793
|
-
into.require(declared === true, "snapshot.sessionCapability.value", `${path}.sessionCapabilities.${name}`, `${name} is declared by presence: send true or omit it`);
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
796
|
validateBreakState(snapshot.break, `${path}.break`, into);
|
|
797
797
|
if (!Array.isArray(snapshot.tasks)) {
|
|
798
798
|
into.add("snapshot.tasks.shape", `${path}.tasks`, "a snapshot must carry a tasks array");
|
|
@@ -838,8 +838,13 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot") {
|
|
|
838
838
|
into.add("snapshot.calendar.shape", `${path}.scheduledActivities`, "scheduledActivities must be an array when present");
|
|
839
839
|
}
|
|
840
840
|
}
|
|
841
|
+
// The login is the permission: a lead's snapshot carries a roster, `[]` included. The other
|
|
842
|
+
// direction -- a roster to a login that does not lead -- is the roster's own rule.
|
|
843
|
+
if (context.capabilities?.team !== undefined && snapshot.team === undefined) {
|
|
844
|
+
into.add("team.required", `${path}.team`, "the login declares capabilities.team, so every snapshot carries a roster: [] when nobody is in it");
|
|
845
|
+
}
|
|
841
846
|
if (snapshot.team !== undefined)
|
|
842
|
-
validateTeamRosterInto(snapshot.team, `${path}.team`, into);
|
|
847
|
+
validateTeamRosterInto(snapshot.team, `${path}.team`, context, into);
|
|
843
848
|
return into.violations;
|
|
844
849
|
}
|
|
845
850
|
// ---------------------------------------------------------------------------
|
|
@@ -912,7 +917,7 @@ function validateProviderSummary(value, path, into) {
|
|
|
912
917
|
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");
|
|
913
918
|
});
|
|
914
919
|
}
|
|
915
|
-
export function validateEventEnvelope(envelope, manifest, path = "event") {
|
|
920
|
+
export function validateEventEnvelope(envelope, manifest, path = "event", context = {}) {
|
|
916
921
|
const into = new Collector();
|
|
917
922
|
if (!isPlainObject(envelope)) {
|
|
918
923
|
into.add("event.shape", path, "an event envelope must be an object");
|
|
@@ -931,7 +936,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event") {
|
|
|
931
936
|
switch (event.type) {
|
|
932
937
|
case "snapshot":
|
|
933
938
|
into.oneOf(event.reason, SNAPSHOT_REASONS, "event.snapshot.reason", `${at}.reason`);
|
|
934
|
-
into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot
|
|
939
|
+
into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, context));
|
|
935
940
|
break;
|
|
936
941
|
case "provider-status":
|
|
937
942
|
into.oneOf(event.status, CONNECTION_STATUSES, "event.providerStatus.status", `${at}.status`);
|
|
@@ -975,7 +980,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event") {
|
|
|
975
980
|
validateProviderSummary(event.summary, `${at}.summary`, into);
|
|
976
981
|
break;
|
|
977
982
|
case "team-updated":
|
|
978
|
-
validateTeamRosterInto(event.team, `${at}.team`, into);
|
|
983
|
+
validateTeamRosterInto(event.team, `${at}.team`, context, into);
|
|
979
984
|
break;
|
|
980
985
|
case "contacts-updated":
|
|
981
986
|
if (!Array.isArray(event.contacts)) {
|
|
@@ -1009,6 +1014,33 @@ function validateUser(value, rule, path, into) {
|
|
|
1009
1014
|
into.require(isUserId(value.id), `${rule}.id`, `${path}.id`, "an identity needs a provider-issued user id");
|
|
1010
1015
|
into.filled(value.displayName, `${rule}.displayName`, `${path}.displayName`, "an identity needs a display name");
|
|
1011
1016
|
}
|
|
1017
|
+
function validateSessionCapabilitiesInto(value, path, into) {
|
|
1018
|
+
if (!isPlainObject(value)) {
|
|
1019
|
+
into.add("authentication.capabilities.shape", path, "a usable login declares its capabilities: an object, {} when it has none");
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
for (const [name, declared] of Object.entries(value)) {
|
|
1023
|
+
if (declared === undefined)
|
|
1024
|
+
continue;
|
|
1025
|
+
if (!into.require(SESSION_CAPABILITIES.includes(name), "authentication.capability.unknown", `${path}.${name}`, `unsupported session capability: ${name}`))
|
|
1026
|
+
continue;
|
|
1027
|
+
if (name === "team") {
|
|
1028
|
+
if (!isPlainObject(declared)) {
|
|
1029
|
+
into.add("authentication.capability.team.shape", `${path}.team`, "team names what the lead may do: an object, {} for a lead with no controls");
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
for (const [control, on] of Object.entries(declared)) {
|
|
1033
|
+
if (on === undefined)
|
|
1034
|
+
continue;
|
|
1035
|
+
if (!into.require(TEAM_CAPABILITIES.includes(control), "authentication.capability.team.unknown", `${path}.team.${control}`, `unsupported team capability: ${control}`))
|
|
1036
|
+
continue;
|
|
1037
|
+
into.require(on === true, "authentication.capability.value", `${path}.team.${control}`, `${control} is declared by presence: send true or omit it`);
|
|
1038
|
+
}
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
into.require(declared === true, "authentication.capability.value", `${path}.${name}`, `${name} is declared by presence: send true or omit it`);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1012
1044
|
export function validateAuthenticationState(state, path = "authentication") {
|
|
1013
1045
|
const into = new Collector();
|
|
1014
1046
|
if (!isPlainObject(state)) {
|
|
@@ -1022,6 +1054,7 @@ export function validateAuthenticationState(state, path = "authentication") {
|
|
|
1022
1054
|
// may carry an identity. Anything else is a state claiming knowledge it does not have.
|
|
1023
1055
|
if (state.status === "authenticated" || state.status === "refreshing") {
|
|
1024
1056
|
validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
|
|
1057
|
+
validateSessionCapabilitiesInto(state.capabilities, `${path}.capabilities`, into);
|
|
1025
1058
|
}
|
|
1026
1059
|
else if (state.status === "expired") {
|
|
1027
1060
|
if (state.identity !== undefined)
|
|
@@ -1044,5 +1077,8 @@ export function validateAuthenticationState(state, path = "authentication") {
|
|
|
1044
1077
|
into.require(state.status === "authenticated", "authentication.expiresAt.unexpected", `${path}.expiresAt`, "only an authenticated state may carry an expiry");
|
|
1045
1078
|
into.timestamp(state.expiresAt, "authentication.expiresAt", `${path}.expiresAt`);
|
|
1046
1079
|
}
|
|
1080
|
+
if (state.status !== "authenticated" && state.status !== "refreshing") {
|
|
1081
|
+
into.require(state.capabilities === undefined, "authentication.capabilities.unexpected", `${path}.capabilities`, `${state.status} must not carry capabilities`);
|
|
1082
|
+
}
|
|
1047
1083
|
return into.violations;
|
|
1048
1084
|
}
|
package/guide.md
CHANGED
|
@@ -21,7 +21,7 @@ are used precisely throughout and mean nothing looser here.
|
|
|
21
21
|
| **Provider** | One independently connected external system: a voice platform, a chat platform, a mail platform. |
|
|
22
22
|
| **Adapter** | The package implementing this contract for one provider. One adapter is one provider, so the words are often interchangeable; *provider* names the system, *adapter* the code speaking for it. |
|
|
23
23
|
| **Agent** | The person signed in and taking work. Not to be confused with a transfer destination whose `kind` is `agent`, which is a routing target. |
|
|
24
|
-
| **Lead** | An agent
|
|
24
|
+
| **Lead** | An agent whose login declares `capabilities.team`. The provider publishes a `TeamRoster` to them and to nobody else: **the login is the permission**. |
|
|
25
25
|
| **Provisioning** | Omni-side policy about this agent, configured outside the protocol and never sent to a provider. It gates whether an offer may be rejected, whether the agent goes ready on login, and whether tasks are auto-accepted. Where a capability and provisioning disagree, the stricter wins. |
|
|
26
26
|
| **Task** | One unit of assigned work — a call, a chat, a mail. |
|
|
27
27
|
| **Channel** | The kind of work a provider carries: `voice`, `chat`, or `email`. Fixed per provider by its manifest. |
|
|
@@ -220,11 +220,21 @@ type AuthenticationContext = {
|
|
|
220
220
|
log?: (entry: unknown) => void;
|
|
221
221
|
};
|
|
222
222
|
|
|
223
|
+
type TeamCapabilities = {
|
|
224
|
+
breakControl?: true;
|
|
225
|
+
consultControl?: true;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
type SessionCapabilities = {
|
|
229
|
+
breaks?: true;
|
|
230
|
+
team?: TeamCapabilities;
|
|
231
|
+
};
|
|
232
|
+
|
|
223
233
|
type AuthenticationState =
|
|
224
234
|
| { status: "signed-out" }
|
|
225
235
|
| { status: "authenticating" }
|
|
226
|
-
| { status: "authenticated"; identity: User; expiresAt?: IsoTimestamp }
|
|
227
|
-
| { status: "refreshing"; identity: User }
|
|
236
|
+
| { status: "authenticated"; identity: User; capabilities: SessionCapabilities; expiresAt?: IsoTimestamp }
|
|
237
|
+
| { status: "refreshing"; identity: User; capabilities: SessionCapabilities }
|
|
228
238
|
| { status: "expired"; identity?: User; failure?: AuthenticationFailure };
|
|
229
239
|
|
|
230
240
|
type AuthenticationFailure = {
|
|
@@ -249,15 +259,9 @@ type ConnectionStatus = "connecting" | "active" | "error";
|
|
|
249
259
|
### Provider state
|
|
250
260
|
|
|
251
261
|
```ts
|
|
252
|
-
type SessionCapabilities = {
|
|
253
|
-
breaks?: true;
|
|
254
|
-
teamBreakControl?: true;
|
|
255
|
-
};
|
|
256
|
-
|
|
257
262
|
type Snapshot = {
|
|
258
263
|
status: ConnectionStatus;
|
|
259
264
|
sessionId: string;
|
|
260
|
-
sessionCapabilities: SessionCapabilities;
|
|
261
265
|
break: BreakState;
|
|
262
266
|
tasks: Task[];
|
|
263
267
|
contacts?: Contact[];
|
|
@@ -608,8 +612,6 @@ type LeadRequest = {
|
|
|
608
612
|
|
|
609
613
|
type TeamRoster = {
|
|
610
614
|
members: TeamMember[];
|
|
611
|
-
breakControl?: true;
|
|
612
|
-
consultControl?: true;
|
|
613
615
|
requests?: LeadRequest[];
|
|
614
616
|
};
|
|
615
617
|
|
|
@@ -1306,8 +1308,8 @@ It carries no identity: who the agent is on this provider is the outcome of auth
|
|
|
1306
1308
|
input to it.
|
|
1307
1309
|
|
|
1308
1310
|
Closing this session releases observers and temporary flow state; it does not sign the agent out.
|
|
1309
|
-
Omni keeps the session open while the provider connection is active so refresh
|
|
1310
|
-
remain observable.
|
|
1311
|
+
Omni keeps the session open while the provider connection is active so refresh, expiry, and
|
|
1312
|
+
capability changes remain observable.
|
|
1311
1313
|
|
|
1312
1314
|
### Authentication state
|
|
1313
1315
|
|
|
@@ -1318,14 +1320,53 @@ reports later changes.
|
|
|
1318
1320
|
| --- | --- |
|
|
1319
1321
|
| `signed-out` | No usable provider session exists. |
|
|
1320
1322
|
| `authenticating` | An interactive `browser-sso` or `credentials` flow is active. |
|
|
1321
|
-
| `authenticated` | A usable session exists. Includes the provider identity and optional token expiry time. |
|
|
1322
|
-
| `refreshing` | The adapter is refreshing its session. Existing provider identity
|
|
1323
|
+
| `authenticated` | A usable session exists. Includes the provider identity, the login's `capabilities`, and optional token expiry time. |
|
|
1324
|
+
| `refreshing` | The adapter is refreshing its session. Existing provider identity and capabilities remain available, unchanged: a change to either is published as `authenticated`. |
|
|
1323
1325
|
| `expired` | The session cannot currently be used. It may include an identity and typed failure. |
|
|
1324
1326
|
|
|
1325
1327
|
Omni calls `connect()` only after authentication reaches `authenticated`. Token refresh remains
|
|
1326
1328
|
adapter-owned; the adapter publishes `refreshing`, followed by `authenticated` or `expired`.
|
|
1327
|
-
|
|
1328
|
-
reauthentication for that
|
|
1329
|
+
`refreshing` asks nothing of the agent and Omni shows nothing for it. If authentication expires
|
|
1330
|
+
during active work, Omni preserves the task workspace and shows reauthentication for that
|
|
1331
|
+
provider: the transport is still up and the tasks still on it, so this is a login problem with a
|
|
1332
|
+
login fix, rendered apart from a provider Omni cannot reach. Commands meanwhile answer
|
|
1333
|
+
`omni.not-authenticated`.
|
|
1334
|
+
|
|
1335
|
+
**Re-authentication restores the login; it does not replace it.** It runs on the session Omni
|
|
1336
|
+
kept, under the same `sessionId` — the old `flowId` died with the expiry, so the adapter issues a
|
|
1337
|
+
new challenge — and when the state returns to `authenticated` the connection and everything on it
|
|
1338
|
+
carry on: Omni does not call `connect()` again, since a second connection would be a second
|
|
1339
|
+
session for one agent. What "signing in again replaces the login" describes is a new
|
|
1340
|
+
`AuthenticationSession` under a new `sessionId`, after `signed-out`.
|
|
1341
|
+
|
|
1342
|
+
### What the login may do
|
|
1343
|
+
|
|
1344
|
+
`capabilities` declares provider actions available to this login rather than to one task: whether
|
|
1345
|
+
the agent may ask for a break, and whether they lead a team — and if so, whether they decide its
|
|
1346
|
+
breaks and whether they may join a member's call. It is declared by presence, like every capability
|
|
1347
|
+
in this contract, and it travels with the identity because it is part of who the agent is on this
|
|
1348
|
+
provider: the provider knows the roles, and says so at sign-in rather than leaving Omni to infer
|
|
1349
|
+
them from what arrives later.
|
|
1350
|
+
|
|
1351
|
+
| Field | Contract |
|
|
1352
|
+
| --- | --- |
|
|
1353
|
+
| `breaks` | This login may request a break. Requires the four break methods on the connection. |
|
|
1354
|
+
| `team` | This login leads a team. The provider publishes a `TeamRoster` to it on every snapshot — `[]` when nobody is in it — and to nobody else. |
|
|
1355
|
+
| `team.breakControl` | This lead decides their team's breaks. Requires `executeTeamBreak`. |
|
|
1356
|
+
| `team.consultControl` | This lead may join a member's call on request. Requires `executeTeamConsult`. |
|
|
1357
|
+
|
|
1358
|
+
A session action is available only when both the capability and Omni provisioning permit it.
|
|
1359
|
+
|
|
1360
|
+
**Capabilities are current, not fixed.** They describe the login as of its latest `authenticated`
|
|
1361
|
+
state. A provider that reads roles live — a lead demoted mid-shift — republishes `authenticated`
|
|
1362
|
+
with the new set through `subscribe()` on the authentication session, which Omni keeps open for
|
|
1363
|
+
the life of the connection for exactly this reason, and the next snapshot agrees with it. Omni
|
|
1364
|
+
provisions what the capabilities call for at sign-in — a team panel for a lead, empty until the
|
|
1365
|
+
roster arrives, and nothing for anybody else — and withdraws it on the next render when the
|
|
1366
|
+
capability goes. A command that arrives after its capability was withdrawn is answered `failed`
|
|
1367
|
+
with `omni.capability-not-enabled`: the provider names it, so Omni never has to infer from a
|
|
1368
|
+
capability change it may not have rendered yet that "you are no longer a lead" is the message
|
|
1369
|
+
rather than "that did not work".
|
|
1329
1370
|
|
|
1330
1371
|
### Starting authentication
|
|
1331
1372
|
|
|
@@ -1391,14 +1432,16 @@ settles. The adapter must not persist raw credentials. Field-specific failures m
|
|
|
1391
1432
|
|
|
1392
1433
|
`cancelAuthentication(flowId)` cancels an abandoned Browser SSO window or credentials form and
|
|
1393
1434
|
releases its temporary state. It does not sign out an already authenticated session. It answers
|
|
1394
|
-
`
|
|
1435
|
+
`accepted`; a repeat, or a flow that already ended, is nothing to act on and answers `accepted`
|
|
1436
|
+
too, by the rule that a command asking for a state answers success when that state holds.
|
|
1395
1437
|
|
|
1396
1438
|
### Completion and failures
|
|
1397
1439
|
|
|
1398
|
-
`complete()` returns either
|
|
1440
|
+
`complete()` returns either the authenticated state — identity and capabilities — or a typed
|
|
1441
|
+
failure:
|
|
1399
1442
|
|
|
1400
1443
|
```ts
|
|
1401
|
-
{ status: "authenticated", identity: { id: "1042", displayName: "Asha Rao" } }
|
|
1444
|
+
{ status: "authenticated", identity: { id: "1042", displayName: "Asha Rao" }, capabilities: { breaks: true } }
|
|
1402
1445
|
```
|
|
1403
1446
|
|
|
1404
1447
|
The `User` it carries is the **root of this provider's user namespace**. Every other person this
|
|
@@ -1416,7 +1459,7 @@ authorization codes, tokens, or provider responses containing secrets.
|
|
|
1416
1459
|
|
|
1417
1460
|
### Sign-out
|
|
1418
1461
|
|
|
1419
|
-
`signOut(
|
|
1462
|
+
`signOut()` revokes or invalidates the provider session where supported, deletes stored
|
|
1420
1463
|
session secrets, and moves state to `signed-out`.
|
|
1421
1464
|
`close()` stops authentication-state observation but does not sign the agent out.
|
|
1422
1465
|
|
|
@@ -1462,21 +1505,19 @@ because Omni renders that form itself. That is a local convenience and never rea
|
|
|
1462
1505
|
|
|
1463
1506
|
### `Snapshot`
|
|
1464
1507
|
|
|
1465
|
-
`
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
The snapshot replaces the set completely, so a resync can grant or withdraw a capability safely.
|
|
1508
|
+
What the login may do is declared on its `AuthenticationState` — see **What the login may do** —
|
|
1509
|
+
not here. The snapshot carries what the provider holds for the agent now, and where that depends on
|
|
1510
|
+
a capability it agrees with the login: a lead's snapshot carries `team`, nobody else's does.
|
|
1469
1511
|
|
|
1470
1512
|
| Field | Contract |
|
|
1471
1513
|
| --- | --- |
|
|
1472
1514
|
| `status` | Current `ConnectionStatus` — whether this provider's transport can serve the session. Defined under **`provider-status`**. |
|
|
1473
1515
|
| `sessionId` | Identity of this login session. It must match the connection context. |
|
|
1474
|
-
| `sessionCapabilities` | Complete provider capability set for this login. Effective permission is its intersection with Omni provisioning. |
|
|
1475
1516
|
| `break` | Complete break state, including approval, accepting state, reasons, retry details, and any imposed break. |
|
|
1476
1517
|
| `tasks` | Complete set of tasks currently offered to or owned by this agent. |
|
|
1477
1518
|
| `contacts` | Required complete contact contribution when the manifest declares `contacts`; `[]` clears it. Omitted only when it does not. |
|
|
1478
1519
|
| `scheduledActivities` | Required complete calendar contribution when the manifest declares `calendar`; `[]` clears it. Omitted only when it does not. |
|
|
1479
|
-
| `team` | `TeamRoster`
|
|
1520
|
+
| `team` | Required `TeamRoster` when the login declares `capabilities.team`, `[]` when nobody is in it. Forbidden otherwise — the login is the permission. |
|
|
1480
1521
|
|
|
1481
1522
|
## Live connection
|
|
1482
1523
|
|
|
@@ -1493,15 +1534,15 @@ surface in one place, and what obliges an adapter to implement each one.
|
|
|
1493
1534
|
| `execute(request)` | Always. Every channel has commands no capability gates — see **Which commands need a capability**. |
|
|
1494
1535
|
| `describeUsers(ids)` | The adapter publishes any `UserId`: on `ImposedBreak.by`, a roster, or `handlingHistory[].by`. |
|
|
1495
1536
|
| `dial(request)` | The manifest declares `idleCapabilities.dial`. |
|
|
1496
|
-
| `requestBreak(request)` | `
|
|
1497
|
-
| `commitBreak()` | `
|
|
1498
|
-
| `cancelBreak()` | `
|
|
1499
|
-
| `endBreak()` | `
|
|
1500
|
-
| `executeTeamBreak(command)` | The
|
|
1501
|
-
| `executeTeamConsult(command)` | The
|
|
1537
|
+
| `requestBreak(request)` | The login declares `capabilities.breaks`. |
|
|
1538
|
+
| `commitBreak()` | The login declares `capabilities.breaks`. Commit and cancel are not optional halves of it. |
|
|
1539
|
+
| `cancelBreak()` | The login declares `capabilities.breaks`. |
|
|
1540
|
+
| `endBreak()` | The login declares `capabilities.breaks`. |
|
|
1541
|
+
| `executeTeamBreak(command)` | The login declares `capabilities.team.breakControl`. |
|
|
1542
|
+
| `executeTeamConsult(command)` | The login declares `capabilities.team.consultControl`. |
|
|
1502
1543
|
| `openMedia(request)` | The manifest channel is `voice`. Every voice task's audio lands in Omni, so there is no voice adapter that does not implement it. |
|
|
1503
1544
|
|
|
1504
|
-
**The four break methods stand or fall together.** Declaring `
|
|
1545
|
+
**The four break methods stand or fall together.** Declaring `capabilities.breaks` at login and then
|
|
1505
1546
|
implementing `requestBreak` without `commitBreak` leaves an agent granted a break that can never
|
|
1506
1547
|
start, and the two-phase coordination in **Coordinating a multi-provider break** has no way to
|
|
1507
1548
|
report that: `granted` is a promise to honour a later commit.
|
|
@@ -2429,7 +2470,7 @@ outstanding and offers **Cancel break request**, but does not tell the agent tha
|
|
|
2429
2470
|
begun.
|
|
2430
2471
|
|
|
2431
2472
|
Omni offers the aggregate Break control only when every provider currently holding capacity
|
|
2432
|
-
declares `
|
|
2473
|
+
declares `capabilities.breaks` at login. If one cannot be stopped, offering a global break would
|
|
2433
2474
|
knowingly permit partial availability.
|
|
2434
2475
|
|
|
2435
2476
|
Omni coordinates one attempt as follows:
|
|
@@ -2553,10 +2594,8 @@ A lead who also takes calls sees their team on the idle dashboard. `Snapshot.tea
|
|
|
2553
2594
|
|
|
2554
2595
|
| Field | Contract |
|
|
2555
2596
|
| --- | --- |
|
|
2556
|
-
| `members` | Every member of this lead's team, whatever their state. `[]` says the lead has a team with nobody in it; omitting the roster says something else entirely — see **
|
|
2557
|
-
| `
|
|
2558
|
-
| `consultControl` | Present when this lead may join a member's call on request, absent when they may not. |
|
|
2559
|
-
| `requests` | The members currently asking this lead to join a call, each with the task and the note. Omitted when the lead may not be asked; `[]` when nobody is asking. See **Consulting a lead**. |
|
|
2597
|
+
| `members` | Every member of this lead's team, whatever their state. `[]` says the lead has a team with nobody in it; omitting the roster says something else entirely — see **The login is the permission** below. |
|
|
2598
|
+
| `requests` | The members currently asking this lead to join a call, each with the task and the note. Required when the login declares `team.consultControl`, `[]` when nobody is asking; omitted when it does not. See **Consulting a lead**. |
|
|
2560
2599
|
|
|
2561
2600
|
| `TeamMember` field | Contract |
|
|
2562
2601
|
| --- | --- |
|
|
@@ -2585,19 +2624,20 @@ everybody — worse than showing nothing, because it looks like data. Send it on
|
|
|
2585
2624
|
knows when the state actually began. It times the current `availability`, so it moves every time
|
|
2586
2625
|
that value does.
|
|
2587
2626
|
|
|
2588
|
-
**
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2627
|
+
**The login is the permission.** A roster goes to a login that declares `capabilities.team`, on
|
|
2628
|
+
every snapshot, and to nobody else. Omni never decides who leads a team: the provider said so at
|
|
2629
|
+
sign-in, and the roster agrees with it — present, `[]` included, for a lead; absent for everybody
|
|
2630
|
+
else, which is the correct rendering for an agent who leads nobody. What the lead may do with the
|
|
2631
|
+
roster is on the login too, `team.breakControl` and `team.consultControl`, never on the roster.
|
|
2592
2632
|
|
|
2593
2633
|
**The roster never carries the agent it is published to — not in `members`, and not in
|
|
2594
2634
|
`requests`.** A lead does not report to themself: their own break request and their own ask for a
|
|
2595
2635
|
lead go up to whoever leads them and appear on *that* person's roster, while the requester sees
|
|
2596
2636
|
only their own `BreakState` and their task's `lead` move. An adapter whose platform lists the lead
|
|
2597
|
-
among their own members filters the signed-in identity out before publishing. **
|
|
2598
|
-
role the provider knows, never inferred from who is listed:**
|
|
2599
|
-
publishes `[]`, an agent with no such role publishes nothing, and no
|
|
2600
|
-
two apart.
|
|
2637
|
+
among their own members filters the signed-in identity out before publishing. **Being a lead is a
|
|
2638
|
+
role the provider knows, never inferred from who is listed:** it is declared at sign-in, a lead
|
|
2639
|
+
with nobody in their team publishes `[]`, an agent with no such role publishes nothing, and no
|
|
2640
|
+
member count can tell those two apart.
|
|
2601
2641
|
|
|
2602
2642
|
### Lead commands
|
|
2603
2643
|
|
|
@@ -2634,8 +2674,8 @@ team, and a second lead method beside `executeTeamBreak`:
|
|
|
2634
2674
|
executeTeamConsult({ command: TeamConsultCommand }): Promise<TeamCommandResult>
|
|
2635
2675
|
```
|
|
2636
2676
|
|
|
2637
|
-
Required when the
|
|
2638
|
-
is by `breakControl`. The flow, in order:
|
|
2677
|
+
Required when the login declares `capabilities.team.consultControl`, and gated by it exactly as
|
|
2678
|
+
`executeTeamBreak` is by `team.breakControl`. The flow, in order:
|
|
2639
2679
|
|
|
2640
2680
|
```ts
|
|
2641
2681
|
// 1. The agent asks, with a small note. Their task carries `lead` from here on.
|
|
@@ -2718,8 +2758,8 @@ whole and replaced whole, and a provider that cannot say omits it.
|
|
|
2718
2758
|
|
|
2719
2759
|
**An agent is not waiting on one person.** Authority is held by several, everyone who holds it
|
|
2720
2760
|
sees the request on their own console, and **any one of them settles it**. Omni offers the
|
|
2721
|
-
decision to
|
|
2722
|
-
|
|
2761
|
+
decision to every login that declares `team.breakControl` — which is how the provider already
|
|
2762
|
+
says who may decide — and does not try to work out whose turn it is.
|
|
2723
2763
|
|
|
2724
2764
|
A request needing *more than one* approval is not something this contract describes. There is
|
|
2725
2765
|
no partial state to report and no progress to display: a request is either still owed a
|
|
@@ -2896,8 +2936,8 @@ react rather than only display the message:
|
|
|
2896
2936
|
|
|
2897
2937
|
| Code | Meaning |
|
|
2898
2938
|
| --- | --- |
|
|
2899
|
-
| `omni.not-authenticated` | The provider session is no longer usable. Omni surfaces reauthentication. |
|
|
2900
|
-
| `omni.capability-not-enabled` | The action targets a capability this task or
|
|
2939
|
+
| `omni.not-authenticated` | The provider session is no longer usable. The adapter has published `expired` at or before this answer — the state is what Omni surfaces reauthentication from; the code says why this action failed, and is never the only signal. |
|
|
2940
|
+
| `omni.capability-not-enabled` | The action targets a capability this task, manifest, or login did not declare — including a lead command from a login whose `capabilities` no longer carry it. |
|
|
2901
2941
|
| `omni.task-not-found` | The provider-local task id is unknown, typically after the task already ended. |
|
|
2902
2942
|
| `omni.destination-not-permitted` | The dial or transfer destination violates the provider's policy. |
|
|
2903
2943
|
| `omni.rate-limited` | The action was throttled. Pair with `retryAfterMs`. |
|
|
@@ -2967,9 +3007,10 @@ direction — the provider asking Omni to reconcile — and neither replaces the
|
|
|
2967
3007
|
|
|
2968
3008
|
Carries a complete `Snapshot` after reconnect or when the provider explicitly requests
|
|
2969
3009
|
reconciliation. `reason` is `reconnected` or `provider-requested`. Omni replaces the provider's
|
|
2970
|
-
current status,
|
|
2971
|
-
|
|
2972
|
-
|
|
3010
|
+
current status, break state, tasks, contacts, scheduled activities and team roster with this
|
|
3011
|
+
snapshot. It carries what the login's capabilities call for — a roster for a lead, on every
|
|
3012
|
+
snapshot — and nothing they do not; a capability is withdrawn by a republished `authenticated`,
|
|
3013
|
+
never by an omission from a snapshot.
|
|
2973
3014
|
|
|
2974
3015
|
### `provider-status`
|
|
2975
3016
|
|
|
@@ -3067,7 +3108,8 @@ each connected provider.
|
|
|
3067
3108
|
|
|
3068
3109
|
Replaces this provider's complete `TeamRoster`. It is emitted only for an agent the provider
|
|
3069
3110
|
publishes a roster to, and it carries the whole team every time — never a change to it, for the
|
|
3070
|
-
reason set out under **Team leads**.
|
|
3111
|
+
reason set out under **Team leads**. A lead's snapshot always carries the roster; it goes only when
|
|
3112
|
+
a republished `authenticated` no longer declares `capabilities.team`.
|
|
3071
3113
|
|
|
3072
3114
|
### `contacts-updated`
|
|
3073
3115
|
|
|
@@ -3106,16 +3148,26 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
3106
3148
|
| --- | --- |
|
|
3107
3149
|
| `validateManifest(manifest)` | Identity, protocol-version interoperability, authentication methods, and idle-capability shapes. |
|
|
3108
3150
|
| `validateTask(task, { channel })` | Identity, channel agreement, phase, completion allowance, capability shapes, custom controls, and browsers. |
|
|
3109
|
-
| `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, and every task, contact, and activity, including capability gating. |
|
|
3151
|
+
| `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, and every task, contact, and activity, including idle-capability gating. |
|
|
3110
3152
|
| `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
|
|
3111
3153
|
| `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
|
|
3112
3154
|
| `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
|
|
3113
|
-
| `validateAuthenticationState(state)` | The identity each state must carry, and the expiry that only `authenticated` may. |
|
|
3155
|
+
| `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. |
|
|
3114
3156
|
|
|
3115
3157
|
Each returns `ProtocolViolation[]` rather than throwing, so a caller can report every problem at
|
|
3116
3158
|
once. A violation carries a stable `rule` id such as `task.browser.url.scheme`, the `path` it was
|
|
3117
3159
|
found at such as `snapshot.tasks[0].browsers[1].url`, and a `message`.
|
|
3118
3160
|
|
|
3161
|
+
Some rules need to know who is reading. `validateTeamRoster`, `validateSnapshot`, and
|
|
3162
|
+
`validateEventEnvelope` take an optional final `{ self, capabilities }` — the signed-in agent's
|
|
3163
|
+
`AuthenticationState.identity.id` and their login's `capabilities`. Given `self`, a roster that
|
|
3164
|
+
carries that agent reports `team.member.self` or `team.request.self`. Given `capabilities`, a lead's
|
|
3165
|
+
snapshot without a roster reports `team.required`, a roster published to a login that does not lead
|
|
3166
|
+
reports `team.unentitled`, `requests` on a roster whose login lacks `team.consultControl` reports
|
|
3167
|
+
`team.requests.capability`, and a roster without them on a login that declares it reports
|
|
3168
|
+
`team.requests.required`. Without them those rules are not checked, because they cannot be.
|
|
3169
|
+
`exerciseAdapter` always passes both.
|
|
3170
|
+
|
|
3119
3171
|
`assertNoViolations(violations)` throws `ProtocolConformanceError` — which carries the full
|
|
3120
3172
|
`violations` array — when the list is non-empty.
|
|
3121
3173
|
|
|
@@ -3131,9 +3183,10 @@ reaching the workspace.
|
|
|
3131
3183
|
Adapter conformance exercise from `@xema/omni-protocol/testing`.
|
|
3132
3184
|
|
|
3133
3185
|
It validates the manifest, opens an authenticated session, connects, checks required capability
|
|
3134
|
-
methods, subscribes, validates the snapshot
|
|
3135
|
-
|
|
3136
|
-
|
|
3186
|
+
methods, subscribes, validates the snapshot, every delivered event, and every authentication state
|
|
3187
|
+
the session publishes during the run — each against the latest login, since capabilities are
|
|
3188
|
+
current, not fixed — states a capacity, then unsubscribes and disconnects. Provider packages should
|
|
3189
|
+
run it with a deterministic test transport and authentication state.
|
|
3137
3190
|
|
|
3138
3191
|
By default it throws `ProtocolConformanceError` listing every violation. Pass
|
|
3139
3192
|
`{ collectOnly: true }` to receive them on the result instead:
|
|
@@ -3144,15 +3197,24 @@ expect(result.violations).toEqual([]);
|
|
|
3144
3197
|
expect(result.disconnectWasClean).toBe(true);
|
|
3145
3198
|
```
|
|
3146
3199
|
|
|
3147
|
-
|
|
3200
|
+
`result.authenticationState` is the state the session was restored with; `result.login` is the
|
|
3201
|
+
latest the session published during the run, which differs only when the adapter republished
|
|
3202
|
+
`authenticated`. A published state that fails validation is reported and not adopted, and a
|
|
3203
|
+
`refreshing` state must carry over the login it refreshes — a different identity is
|
|
3204
|
+
`authentication.refreshing.identity`, a changed capability set `authentication.refreshing.capabilities`.
|
|
3205
|
+
A capability granted by a later login requires its methods just as one declared at sign-in does.
|
|
3206
|
+
|
|
3207
|
+
Three properties of the harness matter to adapter authors:
|
|
3148
3208
|
|
|
3149
3209
|
- **Violations are collected, never thrown from inside the subscribe listener.** Throwing there
|
|
3150
3210
|
would unwind through the provider's own dispatch for a synchronous emitter, and would be
|
|
3151
3211
|
swallowed as an unhandled rejection for an asynchronous one — letting a non-conforming async
|
|
3152
3212
|
adapter pass.
|
|
3153
|
-
- **Resources are released even when the adapter fails.**
|
|
3213
|
+
- **Resources are released even when the adapter fails.** Every unsubscribe, `disconnect()`, and
|
|
3154
3214
|
`close()` run in a `finally` block, and a throw from any of them is reported as
|
|
3155
3215
|
`disconnectWasClean: false` rather than being hidden.
|
|
3216
|
+
- **The login is read, never captured.** Everything is validated against the latest
|
|
3217
|
+
`authenticated` state, so a withdrawal published before a snapshot is held against that snapshot.
|
|
3156
3218
|
|
|
3157
3219
|
### Contract scenarios
|
|
3158
3220
|
|
|
@@ -3161,7 +3223,9 @@ cannot be established from TypeScript structure alone.
|
|
|
3161
3223
|
|
|
3162
3224
|
| Helper | Contract checked |
|
|
3163
3225
|
| --- | --- |
|
|
3164
|
-
| `
|
|
3226
|
+
| `assertCapabilityWithdrawal(states, snapshot, manifest)` | A capability withdrawn by a later `authenticated` state is gone from the next snapshot: no roster for a login that no longer leads, no requests for one that may no longer join. Every state is validated on the way, `refreshing` must carry the login over, and the sequence passes only through usable states. |
|
|
3227
|
+
| `assertCommandRefusedAfterWithdrawal(result)` | A command that arrives after its capability was withdrawn fails with `omni.capability-not-enabled`, named by the provider. |
|
|
3228
|
+
| `assertAuthenticationRestoreAndExpiry(states)` | A restored authenticated session can refresh and ends in expiry. Every state is validated. |
|
|
3165
3229
|
| `assertReconnectWithMissedAssignments(before, reconnect, ids)` | A reconnect snapshot restores assignments received while offline. |
|
|
3166
3230
|
| `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
|
|
3167
3231
|
| `assertWrapTimeout(task, mediaEndedAt, deadline, toleranceMs?)` | The wrap deadline equals media end plus the task allowance, within a tolerance that defaults to 1000ms; a task with no allowance has no deadline, and one observed is the violation. |
|