@xema/omni-protocol 0.1.124 → 0.1.126
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/index.d.ts +26 -4
- package/dist/testing.js +70 -3
- package/dist/validation.d.ts +6 -0
- package/dist/validation.js +33 -1
- package/guide.md +68 -16
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -286,6 +286,14 @@ export interface AuthenticationContext {
|
|
|
286
286
|
* a voice provider and absent for any other. The same value arrives later as `ConnectContext.phone`.
|
|
287
287
|
*/
|
|
288
288
|
phone?: Phone;
|
|
289
|
+
/**
|
|
290
|
+
* Where the provider's sign-in page sends the agent back, fixed for this host and adapter so the
|
|
291
|
+
* provider registers it once and its issuer compares it exactly (RFC 9700). Required when the
|
|
292
|
+
* host offers `browser-sso`. One of RFC 8252's three forms: `https`, a reverse-domain private-use
|
|
293
|
+
* scheme such as `com.example.omni:/auth/acme-voice`, or loopback `http://127.0.0.1:<port>/...`,
|
|
294
|
+
* where only the port varies. Never minted per flow: the flow travels in OAuth `state`.
|
|
295
|
+
*/
|
|
296
|
+
redirectUri?: string;
|
|
289
297
|
/** Scoped to this provider's manifest id. */
|
|
290
298
|
secrets: SecretStore;
|
|
291
299
|
signal?: AbortSignal;
|
|
@@ -389,7 +397,6 @@ export type AuthenticationChallenge = {
|
|
|
389
397
|
export type StartAuthenticationRequest = {
|
|
390
398
|
requestId: string;
|
|
391
399
|
method: "browser-sso";
|
|
392
|
-
callbackUrl: string;
|
|
393
400
|
} | {
|
|
394
401
|
requestId: string;
|
|
395
402
|
method: "credentials";
|
|
@@ -401,10 +408,15 @@ export type StartAuthenticationResult = {
|
|
|
401
408
|
status: "rejected";
|
|
402
409
|
failure: AuthenticationFailure;
|
|
403
410
|
};
|
|
404
|
-
export type CompleteAuthenticationRequest =
|
|
405
|
-
|
|
411
|
+
export type CompleteAuthenticationRequest =
|
|
412
|
+
/**
|
|
413
|
+
* The whole URL the browser came back to, query included. It names no flow: the adapter finds
|
|
414
|
+
* its flow by the `state` it put in the authorization request, and refuses a state it never issued.
|
|
415
|
+
*/
|
|
416
|
+
{
|
|
406
417
|
method: "browser-sso";
|
|
407
418
|
callbackUrl: string;
|
|
419
|
+
flowId?: never;
|
|
408
420
|
} | {
|
|
409
421
|
flowId: string;
|
|
410
422
|
method: "credentials";
|
|
@@ -678,8 +690,18 @@ export type ConnectContext = {
|
|
|
678
690
|
* Mute is tied to the phone by the arms below.
|
|
679
691
|
*/
|
|
680
692
|
host: Host;
|
|
693
|
+
/** The agent application giving up on this sign-in: stop promptly and reject `connect()` with `signal.reason`. */
|
|
681
694
|
signal?: AbortSignal;
|
|
682
695
|
log?: (entry: unknown) => void;
|
|
696
|
+
/**
|
|
697
|
+
* The transport-status `connecting` arm, for while `connect()` is still pending and there is no
|
|
698
|
+
* Connection to carry it: a seat limit, a sign-in queue. Callable only until `connect()` settles;
|
|
699
|
+
* from then on the Connection's own `transport-status` speaks. Optional for hosts.
|
|
700
|
+
*/
|
|
701
|
+
transportStatus?: (update: {
|
|
702
|
+
status: "connecting";
|
|
703
|
+
message?: string;
|
|
704
|
+
}) => void;
|
|
683
705
|
} & (
|
|
684
706
|
/**
|
|
685
707
|
* On a softphone the host holds the microphone and states what its Mute does; on a hardphone,
|
|
@@ -1814,7 +1836,7 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1814
1836
|
type: "transport-status";
|
|
1815
1837
|
status: "error";
|
|
1816
1838
|
recovery: TransportRecovery;
|
|
1817
|
-
message
|
|
1839
|
+
message: string;
|
|
1818
1840
|
} | {
|
|
1819
1841
|
type: "break-state";
|
|
1820
1842
|
break: BreakState;
|
package/dist/testing.js
CHANGED
|
@@ -379,8 +379,18 @@ export async function testAdapter(adapter, context, options = {}) {
|
|
|
379
379
|
report: () => { consulted.report = true; return context.host.report(); },
|
|
380
380
|
subscribe: listener => { consulted.subscribe = true; return context.host.subscribe(listener); },
|
|
381
381
|
};
|
|
382
|
-
|
|
383
|
-
|
|
382
|
+
// A cancelled sign-in fails with the cancellation's own reason. The probe hands connect() a
|
|
383
|
+
// signal already aborted, so the answer is exact and owes nothing to the machine's speed; it
|
|
384
|
+
// gets the host and store unwrapped, so what it consults is not counted for the real connect.
|
|
385
|
+
violations.push(...await cancelledConnect(adapter, context));
|
|
386
|
+
const connecting = connectingReports({ ...context, host, store: watched.store }, violations);
|
|
387
|
+
const connected = connecting.context;
|
|
388
|
+
try {
|
|
389
|
+
connection = await adapter.connect(connected);
|
|
390
|
+
}
|
|
391
|
+
finally {
|
|
392
|
+
connecting.settled();
|
|
393
|
+
}
|
|
384
394
|
// The standing client: the first connection, then the second after a hand-over. Everything the
|
|
385
395
|
// harness asks of a connection it asks of the one that stands.
|
|
386
396
|
let live = connection;
|
|
@@ -892,6 +902,56 @@ function refreshingCarriesOver(login, state, path) {
|
|
|
892
902
|
}
|
|
893
903
|
return found;
|
|
894
904
|
}
|
|
905
|
+
/** Calls `connect()` on an already-cancelled signal and names an adapter that connects anyway or fails for another reason. */
|
|
906
|
+
async function cancelledConnect(adapter, context) {
|
|
907
|
+
const cancel = new AbortController();
|
|
908
|
+
cancel.abort(new Error("the agent application cancelled this sign-in"));
|
|
909
|
+
const { transportStatus: _unused, ...rest } = context;
|
|
910
|
+
try {
|
|
911
|
+
const stray = await adapter.connect({ ...rest, signal: cancel.signal });
|
|
912
|
+
try {
|
|
913
|
+
await stray.disconnect();
|
|
914
|
+
}
|
|
915
|
+
catch { /* the connection that should not exist is ended as far as it can be */ }
|
|
916
|
+
return [{ rule: "connect.signal.aborted", path: "connect.signal",
|
|
917
|
+
message: "connect() resolved on a signal already aborted; a cancelled sign-in fails with signal.reason" }];
|
|
918
|
+
}
|
|
919
|
+
catch (error) {
|
|
920
|
+
return error === cancel.signal.reason ? [] : [{ rule: "connect.signal.reason", path: "connect.signal",
|
|
921
|
+
message: `connect() on an aborted signal failed with ${String(error)}, not with signal.reason` }];
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* The host's `transportStatus`, handed on so that each report is checked: the `connecting` arm,
|
|
926
|
+
* and only while `connect()` is pending. After it settles the connection carries the status.
|
|
927
|
+
*/
|
|
928
|
+
function connectingReports(context, violations) {
|
|
929
|
+
const report = context.transportStatus;
|
|
930
|
+
if (report === undefined)
|
|
931
|
+
return { context, settled: () => { } };
|
|
932
|
+
let settled = false;
|
|
933
|
+
return {
|
|
934
|
+
context: {
|
|
935
|
+
...context,
|
|
936
|
+
transportStatus: (update) => {
|
|
937
|
+
const at = "connect.transportStatus";
|
|
938
|
+
if (settled) {
|
|
939
|
+
violations.push({ rule: "connect.transportStatus.late", path: at,
|
|
940
|
+
message: "reported through the context after connect() settled; the connection's own transport-status speaks from then on" });
|
|
941
|
+
}
|
|
942
|
+
if (!isRecord(update) || update.status !== "connecting") {
|
|
943
|
+
violations.push({ rule: "connect.transportStatus.status", path: `${at}.status`,
|
|
944
|
+
message: `before a connection exists the only status is connecting, not ${JSON.stringify(isRecord(update) ? update.status : update)}` });
|
|
945
|
+
}
|
|
946
|
+
else if (update.message !== undefined && (typeof update.message !== "string" || update.message.trim() === "")) {
|
|
947
|
+
violations.push({ rule: "connect.transportStatus.message", path: `${at}.message`, message: "a message must not be empty when present" });
|
|
948
|
+
}
|
|
949
|
+
report(update);
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
settled: () => { settled = true; },
|
|
953
|
+
};
|
|
954
|
+
}
|
|
895
955
|
/** Validates each state of a published sequence, and that every `refreshing` carries over the login before it. */
|
|
896
956
|
function assertLoginSequence(states, summary) {
|
|
897
957
|
const found = [];
|
|
@@ -1640,7 +1700,14 @@ async function testOneCall(call) {
|
|
|
1640
1700
|
catch { /* the session that would not restore is closed as far as it can be */ }
|
|
1641
1701
|
return;
|
|
1642
1702
|
}
|
|
1643
|
-
const
|
|
1703
|
+
const reconnecting = connectingReports(call.context, found);
|
|
1704
|
+
let second;
|
|
1705
|
+
try {
|
|
1706
|
+
second = await again.connect(reconnecting.context);
|
|
1707
|
+
}
|
|
1708
|
+
finally {
|
|
1709
|
+
reconnecting.settled();
|
|
1710
|
+
}
|
|
1644
1711
|
// From here the second client is the run's connection, held to everything the first was on
|
|
1645
1712
|
// connect: what it publishes is validated as before.
|
|
1646
1713
|
await call.handOver.up(restoredSession, second);
|
package/dist/validation.d.ts
CHANGED
|
@@ -193,6 +193,12 @@ export interface LoginValidationContext {
|
|
|
193
193
|
levels?: readonly string[];
|
|
194
194
|
}
|
|
195
195
|
export declare function validateAuthenticationFailure(failure: unknown, path?: string): ProtocolViolation[];
|
|
196
|
+
/**
|
|
197
|
+
* A redirect URI in one of RFC 8252's three forms. A private-use scheme is reverse-domain (§7.1),
|
|
198
|
+
* a loopback is an IP literal rather than `localhost` (§8.3) and may vary only its port (§7.3),
|
|
199
|
+
* and none carries a fragment, which the authorization response could not be appended beside.
|
|
200
|
+
*/
|
|
201
|
+
export declare function validateRedirectUri(value: unknown, path?: string): ProtocolViolation[];
|
|
196
202
|
/**
|
|
197
203
|
* What `start()` or `complete()` answered: a challenge or a rejection from `start`, a login or a
|
|
198
204
|
* rejection from `complete`. A rejection carries a failure the host can show, and a login is held
|
package/dist/validation.js
CHANGED
|
@@ -2070,7 +2070,12 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
2070
2070
|
else {
|
|
2071
2071
|
into.require(event.recovery === undefined, "event.transportStatus.recovery.unexpected", `${at}.recovery`, "recovery goes with an error; nothing needs reviving here");
|
|
2072
2072
|
}
|
|
2073
|
-
if (event.
|
|
2073
|
+
if (event.status === "error") {
|
|
2074
|
+
if (into.require(event.message !== undefined, "event.transportStatus.message.required", `${at}.message`, "an error says why, in words the agent sees")) {
|
|
2075
|
+
into.filled(event.message, "event.transportStatus.message", `${at}.message`, "a message must not be empty");
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
else if (event.message !== undefined) {
|
|
2074
2079
|
into.filled(event.message, "event.transportStatus.message", `${at}.message`, "a message must not be empty when present");
|
|
2075
2080
|
}
|
|
2076
2081
|
break;
|
|
@@ -3090,6 +3095,33 @@ export function validateAuthenticationFailure(failure, path = "failure") {
|
|
|
3090
3095
|
validateAuthenticationFailureInto(failure, path, into);
|
|
3091
3096
|
return into.violations;
|
|
3092
3097
|
}
|
|
3098
|
+
/**
|
|
3099
|
+
* A redirect URI in one of RFC 8252's three forms. A private-use scheme is reverse-domain (§7.1),
|
|
3100
|
+
* a loopback is an IP literal rather than `localhost` (§8.3) and may vary only its port (§7.3),
|
|
3101
|
+
* and none carries a fragment, which the authorization response could not be appended beside.
|
|
3102
|
+
*/
|
|
3103
|
+
export function validateRedirectUri(value, path = "redirectUri") {
|
|
3104
|
+
const into = new Collector();
|
|
3105
|
+
if (!into.filled(value, "redirectUri", path, "browser SSO needs the fixed redirect URI the provider registered"))
|
|
3106
|
+
return into.violations;
|
|
3107
|
+
let url;
|
|
3108
|
+
try {
|
|
3109
|
+
url = new URL(value);
|
|
3110
|
+
}
|
|
3111
|
+
catch {
|
|
3112
|
+
into.add("redirectUri", path, `${describeValue(value)} is not an absolute URI`);
|
|
3113
|
+
return into.violations;
|
|
3114
|
+
}
|
|
3115
|
+
into.require(url.hash === "" && !value.includes("#"), "redirectUri.fragment", path, "a redirect URI carries no fragment");
|
|
3116
|
+
const scheme = url.protocol.slice(0, -1);
|
|
3117
|
+
if (scheme === "http") {
|
|
3118
|
+
into.require(url.hostname === "127.0.0.1" || url.hostname === "[::1]", "redirectUri.http", path, "plain http is for loopback only, as 127.0.0.1 or [::1] -- not localhost, which may resolve elsewhere");
|
|
3119
|
+
}
|
|
3120
|
+
else if (scheme !== "https") {
|
|
3121
|
+
into.require(/^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+$/.test(scheme), "redirectUri.scheme", path, `a private-use scheme is a reverse domain name the host controls, as com.example.omni; ${describeValue(scheme)} is not`);
|
|
3122
|
+
}
|
|
3123
|
+
return into.violations;
|
|
3124
|
+
}
|
|
3093
3125
|
function validateAuthenticationChallenge(value, path, into) {
|
|
3094
3126
|
if (!isPlainObject(value)) {
|
|
3095
3127
|
into.add("authentication.result.challenge", path, "interaction-required carries the challenge to put to the person");
|
package/guide.md
CHANGED
|
@@ -290,6 +290,7 @@ type AuthenticationContext = {
|
|
|
290
290
|
loginId: string;
|
|
291
291
|
timeZone: TimeZone;
|
|
292
292
|
phone?: Phone;
|
|
293
|
+
redirectUri?: string;
|
|
293
294
|
secrets: SecretStore;
|
|
294
295
|
signal?: AbortSignal;
|
|
295
296
|
log?: (entry: unknown) => void;
|
|
@@ -427,6 +428,7 @@ type ConnectContext = {
|
|
|
427
428
|
host: Host;
|
|
428
429
|
signal?: AbortSignal;
|
|
429
430
|
log?: (entry: unknown) => void;
|
|
431
|
+
transportStatus?: (update: { status: "connecting"; message?: string }) => void;
|
|
430
432
|
} & (
|
|
431
433
|
| { phone: "softphone"; host: { mute: HostMute } }
|
|
432
434
|
| { phone?: "hardphone"; host: { mute?: never; recording?: never } }
|
|
@@ -447,7 +449,7 @@ type AuthenticationChallenge =
|
|
|
447
449
|
| { flowId: string; method: "credentials"; fields: CredentialField[] };
|
|
448
450
|
|
|
449
451
|
type StartAuthenticationRequest =
|
|
450
|
-
| { requestId: string; method: "browser-sso"
|
|
452
|
+
| { requestId: string; method: "browser-sso" }
|
|
451
453
|
| { requestId: string; method: "credentials" };
|
|
452
454
|
|
|
453
455
|
type StartAuthenticationResult =
|
|
@@ -455,7 +457,7 @@ type StartAuthenticationResult =
|
|
|
455
457
|
| { status: "rejected"; failure: AuthenticationFailure };
|
|
456
458
|
|
|
457
459
|
type CompleteAuthenticationRequest =
|
|
458
|
-
| {
|
|
460
|
+
| { method: "browser-sso"; callbackUrl: string; flowId?: never }
|
|
459
461
|
| { flowId: string; method: "credentials"; values: Readonly<Record<string, string>> };
|
|
460
462
|
|
|
461
463
|
type CompleteAuthenticationResult =
|
|
@@ -1213,7 +1215,7 @@ type TransportRecovery = "reconnect" | "reauthenticate" | "displaced";
|
|
|
1213
1215
|
type ProviderEvent =
|
|
1214
1216
|
| { type: "snapshot"; reason: "reconnected" | "provider-requested"; snapshot: Snapshot }
|
|
1215
1217
|
| { type: "transport-status"; status: "connecting" | "active"; message?: string }
|
|
1216
|
-
| { type: "transport-status"; status: "error"; recovery: TransportRecovery; message
|
|
1218
|
+
| { type: "transport-status"; status: "error"; recovery: TransportRecovery; message: string }
|
|
1217
1219
|
| { type: "break-state"; break: BreakState }
|
|
1218
1220
|
| {
|
|
1219
1221
|
type: "task-offered";
|
|
@@ -2073,7 +2075,7 @@ reports later changes.
|
|
|
2073
2075
|
| `authenticated` | A usable session exists. Includes the provider identity, the login's `capabilities`, and optional token expiry time. |
|
|
2074
2076
|
| `refreshing` | The adapter is refreshing its session. Existing provider identity and capabilities remain available, unchanged: a change to either is published as `authenticated`. |
|
|
2075
2077
|
| `expired` | The session's time ran out, or its refresh is failing. It may include an identity and typed failure. The agent signs in again on the same login, and the workspace stays. |
|
|
2076
|
-
| `terminated` | The session was ended from outside this agent computer: by a supervisor, by the agent on another screen, or by the platform itself. `by` says who, a person by `UserId` or `provider` where no person was behind it (`authentication.terminated.by`), and `failure` says why and is never retryable (`authentication.terminated.failure`, `.retryable`). It is never reported as `expired`, which would hide who ended it, nor as `signed-out`, which is the agent's own act. |
|
|
2078
|
+
| `terminated` | The session was ended from outside this agent computer: by a supervisor, by the agent on another screen, or by the platform itself. `by` says who, a person by `UserId` or `provider` where no person was behind it (`authentication.terminated.by`), and `failure` says why and is never retryable (`authentication.terminated.failure`, `.retryable`). It is never reported as `expired`, which would hide who ended it, nor as `signed-out`, which is the agent's own act. The session's ending is the whole message: the agent application disposes the connection with `disconnect()` and shows `failure.message` on the sign-in, and the adapter reports no transport `error` beside it -- `reauthenticate` would say this session is restored, and it is not. |
|
|
2077
2079
|
|
|
2078
2080
|
Omni calls `connect()` only after authentication reaches `authenticated`. Token refresh remains
|
|
2079
2081
|
adapter-owned; the adapter publishes `refreshing`, followed by `authenticated` or `expired`.
|
|
@@ -2093,6 +2095,14 @@ calls `connect()` afresh, as for `reconnect` -- see **`transport-status`**. What
|
|
|
2093
2095
|
replaces the login" describes is a new `AuthenticationSession` under a new `loginId`, after
|
|
2094
2096
|
`signed-out`.
|
|
2095
2097
|
|
|
2098
|
+
**A sign-in the platform no longer allows is ended, not restored.** A role changed mid-shift can
|
|
2099
|
+
take away what the login was built on -- an agent made a supervisor may no longer have a phone, and
|
|
2100
|
+
the platform refuses to renew the softphone. Retrying cannot help and the same session cannot carry
|
|
2101
|
+
on, so the adapter deletes the secrets and publishes `terminated`, `by: "provider"`, with a failure
|
|
2102
|
+
in the agent's words: `{ code: "phone-not-allowed", message: "Your sign-in no longer allows a phone.
|
|
2103
|
+
Sign in again.", retryable: false }`. The agent application disposes the connection and shows that
|
|
2104
|
+
message on the sign-in.
|
|
2105
|
+
|
|
2096
2106
|
### What the login may do
|
|
2097
2107
|
|
|
2098
2108
|
`capabilities` declares provider actions available to this login rather than to one task: whether
|
|
@@ -2132,17 +2142,32 @@ short-lived, opaque `flowId`; rejection returns `AuthenticationFailure`.
|
|
|
2132
2142
|
|
|
2133
2143
|
#### Browser SSO
|
|
2134
2144
|
|
|
2135
|
-
|
|
2145
|
+
**The redirect URI is fixed, never minted per flow.** An issuer that follows current guidance
|
|
2146
|
+
compares the redirect URI exactly against the one registered (RFC 9700 §2.1, §4.1.3), so a path
|
|
2147
|
+
carrying a request id is refused by every one of them. The host states the URI once, for this host
|
|
2148
|
+
and adapter, as `AuthenticationContext.redirectUri`, and the provider registers it on its public
|
|
2149
|
+
client ahead of time. It takes one of the three forms RFC 8252 gives a native or browser app, as
|
|
2150
|
+
Google, Microsoft Entra and Okta register them:
|
|
2151
|
+
|
|
2152
|
+
| Form | Example | When |
|
|
2153
|
+
| --- | --- | --- |
|
|
2154
|
+
| `https` | `https://agent.example.com/auth/acme-voice` | Omni in a browser tab, or a desktop app with a claimed https link. |
|
|
2155
|
+
| Private-use scheme | `com.example.omni:/auth/acme-voice` | A desktop app. The scheme is a reverse domain name the host controls (RFC 8252 §7.1). |
|
|
2156
|
+
| Loopback | `http://127.0.0.1:53127/auth/acme-voice` | A desktop app listening locally. Only the port varies between flows (§7.3); the host is the IP literal, never `localhost` (§8.3). |
|
|
2157
|
+
|
|
2158
|
+
`validateRedirectUri(uri)` refuses anything else: a scheme that is not a reverse domain, such as
|
|
2159
|
+
`omni-agent:` (`redirectUri.scheme`), plain http to any host but the loopback literal
|
|
2160
|
+
(`redirectUri.http`), and a fragment (`redirectUri.fragment`). Its path names the adapter, so one
|
|
2161
|
+
host serves several providers on one scheme.
|
|
2162
|
+
|
|
2163
|
+
`start()` then carries nothing about the callback:
|
|
2136
2164
|
|
|
2137
2165
|
```ts
|
|
2138
|
-
{
|
|
2139
|
-
requestId: "auth-42",
|
|
2140
|
-
method: "browser-sso",
|
|
2141
|
-
callbackUrl: "omni-agent://auth/acme-voice/auth-42"
|
|
2142
|
-
}
|
|
2166
|
+
{ requestId: "auth-42", method: "browser-sso" }
|
|
2143
2167
|
```
|
|
2144
2168
|
|
|
2145
|
-
The adapter creates the OAuth/OIDC request
|
|
2169
|
+
The adapter creates the OAuth/OIDC request with its OAuth redirect URI set to the context's `redirectUri`,
|
|
2170
|
+
PKCE, OIDC `nonce`, and a `state` that names this flow, and returns:
|
|
2146
2171
|
|
|
2147
2172
|
```ts
|
|
2148
2173
|
{
|
|
@@ -2157,7 +2182,10 @@ The adapter creates the OAuth/OIDC request, including PKCE, `state`, and OIDC `n
|
|
|
2157
2182
|
```
|
|
2158
2183
|
|
|
2159
2184
|
Omni opens the requested `system` or `omni` browser. After redirect, it passes the complete callback
|
|
2160
|
-
URL to `complete()
|
|
2185
|
+
URL, query included, to `complete()` as `{ method: "browser-sso", callbackUrl }`. The request names
|
|
2186
|
+
no flow: **the flow is found by `state`**, which the adapter issued and alone can read, and a
|
|
2187
|
+
`state` it never issued, or already used, is rejected. Omni routes the callback to the adapter by
|
|
2188
|
+
the URI's path and never encodes a request id in it. The adapter validates the flow, exchanges the authorization code, and returns
|
|
2161
2189
|
the authenticated provider identity. Provider tokens never enter Omni UI or general protocol state.
|
|
2162
2190
|
An Omni-hosted SSO browser uses a dedicated temporary authentication session and never shares
|
|
2163
2191
|
cookies or storage with task or personal browsers.
|
|
@@ -2222,6 +2250,9 @@ short bound the adapter states, and then given up: a provider that cannot be rea
|
|
|
2222
2250
|
token on the agent's machine. If it was not told, `signOut()` still deletes and still reaches
|
|
2223
2251
|
`signed-out`, and answers `failed`, so the agent application can show that a session may remain at
|
|
2224
2252
|
the provider. `close()` stops authentication-state observation but does not sign the agent out.
|
|
2253
|
+
The agent application ends the connection itself, with `disconnect()`: the sign-out is its own act,
|
|
2254
|
+
so the adapter reports no transport `error` for it, and a connection closed by the platform as the
|
|
2255
|
+
sign-out lands is not news to anyone.
|
|
2225
2256
|
|
|
2226
2257
|
**The final moments are the agent application's to finish.** Its own steps before signing out --
|
|
2227
2258
|
stopping capacity, putting tasks down -- may be retried within a short bound it states, and then
|
|
@@ -2295,8 +2326,9 @@ Creates one live provider connection for the signed-in agent.
|
|
|
2295
2326
|
| `phone` | The same value passed as `AuthenticationContext.phone`: how this login hears its calls. The type ties `host.mute` to it: a `softphone` login's agent application states what its Mute does, and a hardphone or conversation login's agent application cannot, so the omission is a compile error rather than a live seat's discovery. See **The station is the agent application's** in `guide/phone.md`. |
|
|
2296
2327
|
| `store` | The login's operational store, kept by the agent application for the life of the login, across a reload of the agent application, and cleared at sign-out: where an adapter that composes a record keeps what its platform cannot hold for it, such as the interaction legs an agent application reported. Three functions, by key. Never for anything sensitive, which is `AuthenticationContext.secrets`, a store an agent application may clear aggressively. **A task's keys carry its assignment id and go with the task**: a key written about a task names the assignment id in the key, and is deleted before the task's end is published, so that nothing of a closed task survives its ending and nothing is left for whatever comes next -- a record with legs the agent application never reported against it. A login-scoped key carries no assignment id and outlives any task. The harness requires the store of every connection (`store.shape`, `store.get`, `.set`, `.delete`), watches the one it hands over, and names a task's key still held after `task-ended` (`test.store.retained`) or written about the task after its end -- a persist hung off a timer that saw the task as it was (`test.store.late`). The ordinary late write is the agent application's, not the adapter's: an agent application reports a leg without waiting for the answer, so an unmute can follow `complete` by a tick, and the adapter answers a report about a task that has ended `failed` with `omni.assignment-not-found` and writes nothing -- the agent application ends its own open legs at the task's end, so such a report is the agent application's error to see, and an agent application reads every `recordStep` answer and awaits the one for the leg it closes at a task's end, the only moment a refusal is expected, since a report nobody waits for is an error nobody can see; an adapter that names no task in its keys gets no cleanup check, which is a gap rather than a pass, never an exemption: the obligation is that nothing of a closed task survives its ending, and an adapter that keeps every open task in one login-scoped value owes exactly that inside the value, where the harness cannot look. One key per task, named for it, is the shape the harness can hold, and the shape to reach for. The store lists nothing, so an adapter that needs to find its tasks keeps a login-scoped index of ids beside them; at the task's end it deletes the body first and reindexes after, since a crash between the two then leaves an index naming a task with no body, which a reader skips, where the other order leaves a body for a task that has ended, which is the hazard itself. A reader of the index tolerates an id with no body as an ending that was underway, not as corruption. |
|
|
2297
2328
|
| `host` | The agent application's report of the agent's station — devices, permissions, network — to consult before declaring the agent ready to the platform, and on every change. See **The agent application reports, the adapter decides** in `guide/phone.md`. |
|
|
2298
|
-
| `signal` | Optional cancellation signal.
|
|
2329
|
+
| `signal` | Optional cancellation signal: the agent application giving up on this sign-in. When it is aborted, stop startup promptly, begin no new work, and reject `connect()` with `signal.reason`, as the web platform's own fetch does. The harness hands `connect()` a signal already aborted and names a connection made anyway (`connect.signal.aborted`) or a failure with anything but `signal.reason` (`connect.signal.reason`). |
|
|
2299
2330
|
| `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
|
|
2331
|
+
| `transportStatus` | Optional. The `connecting` arm of **`transport-status`**, for while `connect()` is still pending: same shape, same rule that `message` is safe for the agent to see. Callable only until `connect()` settles. See **Before the connection exists**. |
|
|
2300
2332
|
|
|
2301
2333
|
### The agent's day
|
|
2302
2334
|
|
|
@@ -2666,7 +2698,7 @@ safe for the agent to see.
|
|
|
2666
2698
|
| --- | --- |
|
|
2667
2699
|
| `connecting` | No usable transport right now, and the adapter expects to recover on its own. Nobody needs to act. Startup and every reconnect pass through this value. |
|
|
2668
2700
|
| `active` | The transport is up and the provider is serving this session. It is the only value under which work arrives. |
|
|
2669
|
-
| `error` | The adapter cannot serve the session and is not simply mid-reconnect. Say why in `message
|
|
2701
|
+
| `error` | The adapter cannot serve the session and is not simply mid-reconnect. Say why in `message` (`event.transportStatus.message.required`), and say what revives it in `recovery` — both required here, and `recovery` forbidden on any other status. It is not terminal: an adapter that recovers on its own still reports `connecting` and then `active`, and one that cannot is revived as `recovery` says. |
|
|
2670
2702
|
|
|
2671
2703
|
**An error names its recovery, and the agent application acts on that word.** The adapter knows why its session
|
|
2672
2704
|
died; the agent application knows how to run a login. `recovery` joins the two:
|
|
@@ -2676,7 +2708,10 @@ died; the agent application knows how to run a login. `recovery` joins the two:
|
|
|
2676
2708
|
`connect()` again on the same login — same `loginId` — and the fresh connect snapshot
|
|
2677
2709
|
re-establishes state exactly as a reconnect snapshot does. `connect()` is once per
|
|
2678
2710
|
`Connection`, not once per login.
|
|
2679
|
-
- **`reauthenticate`** — the session under the login
|
|
2711
|
+
- **`reauthenticate`** — the session under the login lapsed and the same login can be restored: a
|
|
2712
|
+
token rejected, published as `expired`. A session ended from outside -- a remote logout, a
|
|
2713
|
+
permission withdrawn -- is `terminated`, which is the whole message and gets no transport `error`
|
|
2714
|
+
beside it (see **Authentication state**). Omni
|
|
2680
2715
|
runs the authentication flow first, on the same session and `loginId`, never through
|
|
2681
2716
|
`signed-out`; the authentication session decides whether stored material refreshes it silently or
|
|
2682
2717
|
the agent must act, exactly as at sign-in. Once the state returns to `authenticated`, Omni
|
|
@@ -2689,6 +2724,19 @@ died; the agent application knows how to run a login. `recovery` joins the two:
|
|
|
2689
2724
|
they are signed in elsewhere, with a way to take the login back, which is a `connect()` that
|
|
2690
2725
|
displaces the other. See **`Adapter.connect(context)`**.
|
|
2691
2726
|
|
|
2727
|
+
**Before the connection exists.** A provider can hold a sign-in back before any connection
|
|
2728
|
+
stands: every agent licence on the site in use, a sign-in queue. The adapter retries as
|
|
2729
|
+
**Patience is the agent application's** asks, but until `connect()` resolves the agent application
|
|
2730
|
+
holds no `Connection` to hear `transport-status` from, and a first sign-in to a full site would
|
|
2731
|
+
just hang. So the adapter reports through `ConnectContext.transportStatus` -- `{ status:
|
|
2732
|
+
"connecting", message }`, the same arm and the same safety rule -- and the agent application
|
|
2733
|
+
renders it exactly as it renders `connecting`. Only `connecting`: a sign-in that cannot succeed
|
|
2734
|
+
rejects `connect()`. It is callable only until `connect()` settles, and from then on the
|
|
2735
|
+
connection's own `transport-status` speaks; the harness names a call made later
|
|
2736
|
+
(`connect.transportStatus.late`), a status other than `connecting` (`connect.transportStatus.status`)
|
|
2737
|
+
and an empty message (`connect.transportStatus.message`). A host may omit it, so an adapter calls
|
|
2738
|
+
it only where it is present.
|
|
2739
|
+
|
|
2692
2740
|
**Patience is the agent application's.** An adapter in `connecting` retries for as long as it takes and never
|
|
2693
2741
|
has to decide when to stop. Omni owns giving up: after however long it chooses to wait, it may
|
|
2694
2742
|
call `disconnect()` and either `connect()` afresh or surface the failure — so neither side waits
|
|
@@ -2706,7 +2754,10 @@ adapter that leaves a stale `active` in place is telling Omni to keep waiting fo
|
|
|
2706
2754
|
come — see **Liveness**.
|
|
2707
2755
|
|
|
2708
2756
|
**The status is seen, not merely known.** An agent application renders the transport's status where the agent
|
|
2709
|
-
works — `connecting` from the moment it is reported, `error` with what revives it — and
|
|
2757
|
+
works — `connecting` from the moment it is reported, `error` with what revives it — and shows the
|
|
2758
|
+
provider's `message` as written, never a generic "disconnected" in its place: the provider knows why,
|
|
2759
|
+
and the words are what tell the agent whether to wait, sign in again or call somebody. The same holds
|
|
2760
|
+
for the `failure.message` of an `expired` or `terminated` session. Never a
|
|
2710
2761
|
healthy workspace over a transport that cannot serve it: an agent talking on an agent computer whose transport
|
|
2711
2762
|
died was an incident, and it happened because the agent computer knew and did not say. The same obligation
|
|
2712
2763
|
covers a `diagnostic`: shown where the agent works, and counted.
|
|
@@ -3034,6 +3085,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
3034
3085
|
| `validateLoginStore(store)` | The login's store the agent application hands every connection: an object with `get`, `set` and `delete` (`store.shape`, `store.get`, `.set`, `.delete`). The harness holds `ConnectContext.store` to it. |
|
|
3035
3086
|
| `validateCapacity(capacity)` | What the agent application states as capacity: a whole number of zero or more (`capacity.count`), zero being agent application-stopped. The harness states one on connect and two, one and zero after the test, each answered `applied`, and holds any offer to the count in force (`stream.taskOffered.overCapacity`). |
|
|
3036
3087
|
| `validateAuthenticationResult(result, method)` | What `start()` or `complete()` answered: a challenge or a rejection, a login or a rejection. A rejection's failure is held to its rules -- an `omni.` code the contract lists, and `omni.phone-not-permitted` never retryable, since the agent's station is configuration. `validateAuthenticationFailure(failure)` is the same check on a failure alone. |
|
|
3088
|
+
| `validateRedirectUri(uri, path?)` | A host's fixed redirect URI in one of RFC 8252's forms: `https`, a reverse-domain private-use scheme, or loopback http to `127.0.0.1` or `[::1]`, with no fragment (`redirectUri.*`). |
|
|
3037
3089
|
| `validateTaskCommand(command, task?)` | What a command needs to be issuable, against the task it names: its own shape -- a dial's `dialId`, a conference's item, a schedule's time, a remove naming exactly one person -- and, with the task, the capability the table above gates it on (`command.capability.<name>`, `.locked`), the phase it belongs to (`command.phase.*`, `command.phase.interaction` for every control on the call or the conversation), and the state that has to stand: a lead requested, somebody else still on the call (`command.conference.remove.alone`), the caller off hold before the agent's part ends (`command.endCall.held`). An agent application validates before sending and an adapter before acting. |
|
|
3038
3090
|
| `validateResult(result, method)` | What a connection method answered: the status it gives, a failure where the status says so and nowhere else, the failure's shape, and that an `omni.` code is one this contract names. |
|
|
3039
3091
|
| `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. Omni applies it to every state a session publishes — the republished as much as the first. |
|