@oxyhq/core 20.1.0 → 21.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +107 -8
- package/dist/cjs/i18n/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/es-ES.json +19 -2
- package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
- package/dist/cjs/index.js +50 -16
- package/dist/cjs/mixins/OxyServices.auth.js +27 -3
- package/dist/cjs/session/SessionClient.js +361 -1
- package/dist/cjs/session/accountDialogController.js +121 -147
- package/dist/cjs/session/accountSwitchTargets.js +75 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/projectSessionState.js +8 -1
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +107 -8
- package/dist/esm/i18n/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/es-ES.json +19 -2
- package/dist/esm/i18n/locales/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
- package/dist/esm/index.js +32 -10
- package/dist/esm/mixins/OxyServices.auth.js +27 -3
- package/dist/esm/session/SessionClient.js +362 -2
- package/dist/esm/session/accountDialogController.js +121 -147
- package/dist/esm/session/accountSwitchTargets.js +71 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/projectSessionState.js +8 -2
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +24 -4
- package/dist/types/index.d.ts +8 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
- package/dist/types/models/session.d.ts +11 -0
- package/dist/types/session/SessionClient.d.ts +202 -1
- package/dist/types/session/accountDialogController.d.ts +76 -64
- package/dist/types/session/accountSwitchTargets.d.ts +64 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- package/dist/types/session/projectSessionState.d.ts +29 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/package.json +3 -3
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/sessionColdBoot.ts +133 -9
- package/src/i18n/locales/en-US.json +19 -2
- package/src/i18n/locales/es-ES.json +19 -2
- package/src/index.ts +75 -18
- package/src/mixins/OxyServices.auth.ts +67 -5
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
- package/src/models/session.ts +11 -0
- package/src/session/SessionClient.ts +386 -1
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/accountDialogController.test.ts +411 -278
- package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- package/src/session/__tests__/projectSessionState.test.ts +17 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDialogController.ts +141 -179
- package/src/session/accountSwitchTargets.ts +87 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/projectSessionState.ts +9 -3
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/dist/cjs/session/accountProjection.js +0 -213
- package/dist/esm/session/accountProjection.js +0 -207
- package/dist/types/session/accountProjection.d.ts +0 -198
- package/src/session/__tests__/accountProjection.test.ts +0 -447
- package/src/session/accountProjection.ts +0 -354
|
@@ -15,10 +15,17 @@
|
|
|
15
15
|
* origin persisted a `deviceId` + `deviceSecret`, mint a short access token
|
|
16
16
|
* with a single bearer-less POST to `/session/device/token` (no cookie, no
|
|
17
17
|
* navigation) and rotate the secret in-use.
|
|
18
|
-
* 3. `shared-
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* 3. `shared-device-adopt` (native, ACCOUNT mode) — this app has no credential
|
|
19
|
+
* of its own but a sibling official app already put one in the shared native
|
|
20
|
+
* slot: adopt it and mint. This is how a newly installed official app joins
|
|
21
|
+
* the device's existing session WITHOUT another QR and without ever touching
|
|
22
|
+
* the Commons private key.
|
|
23
|
+
* 4. `shared-key-signin` (native, ACCOUNT mode) — the legacy lane: re-mint by
|
|
24
|
+
* signing with the shared-keychain IDENTITY key. Retained as a recovery /
|
|
25
|
+
* compatibility path for devices whose apps have not yet published a shared
|
|
26
|
+
* device credential — OR `identity-key-signin` (IDENTITY mode) — re-mint
|
|
27
|
+
* from THIS device's primary identity key.
|
|
28
|
+
* 5. Signed out.
|
|
22
29
|
*
|
|
23
30
|
* Two session modes (see {@link RunSessionColdBootOptions.sessionMode}):
|
|
24
31
|
* - `account` (default) — the device's ACTIVE account owns the session. Every
|
|
@@ -33,6 +40,7 @@
|
|
|
33
40
|
*/
|
|
34
41
|
import { type ColdBootOutcome } from '../utils/coldBoot';
|
|
35
42
|
import { type IdentityBinding } from '../session/identitySession';
|
|
43
|
+
import { type SharedDeviceCredentialStore } from '../session/sharedDeviceCredential';
|
|
36
44
|
import type { OxyServices } from '../OxyServices';
|
|
37
45
|
import type { AuthStateStore } from '../session/authStateStore';
|
|
38
46
|
/**
|
|
@@ -108,6 +116,18 @@ export interface RunSessionColdBootOptions {
|
|
|
108
116
|
* `sessionMode: 'identity'`. Ignored in `'account'` mode.
|
|
109
117
|
*/
|
|
110
118
|
identity?: IdentityBinding;
|
|
119
|
+
/**
|
|
120
|
+
* The cross-app native slot holding this device's shared DeviceSession
|
|
121
|
+
* credential, enabling the `shared-device-adopt` lane. Supplied by
|
|
122
|
+
* `@oxyhq/services` on native; absent on web, where each origin is its own
|
|
123
|
+
* device by design.
|
|
124
|
+
*
|
|
125
|
+
* IGNORED in `sessionMode: 'identity'`. The shared slot belongs to whichever
|
|
126
|
+
* principal signed in on this device; an identity-bound client must resolve
|
|
127
|
+
* its session from the local key alone, and adopting a device credential is
|
|
128
|
+
* exactly the drift that mode exists to prevent.
|
|
129
|
+
*/
|
|
130
|
+
sharedDeviceCredential?: SharedDeviceCredentialStore;
|
|
111
131
|
}
|
|
112
132
|
/**
|
|
113
133
|
* Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
|
package/dist/types/index.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export { ServiceCredentialMismatchError, } from './mixins/OxyServices.auth';
|
|
|
25
25
|
export { getCommonsApprovalBlockingReason, parseCommonsApprovalExpiresAt, } from './utils/commonsApproval';
|
|
26
26
|
export { selectCommonsDelivery, pushTargetsFromDelivery, commonsDeliveryPlatform } from './utils/commonsDelivery';
|
|
27
27
|
export type { CommonsDeliveryFacts, CommonsDeliveryPlatform, CommonsDeliveryRoute, } from './utils/commonsDelivery';
|
|
28
|
-
export type { ServiceTokenResponse, OAuthUserInfoResponse } from './mixins/OxyServices.auth';
|
|
28
|
+
export type { ServiceTokenResponse, OAuthUserInfoResponse, OAuthTokenExchangeResult, } from './mixins/OxyServices.auth';
|
|
29
29
|
export type { CommonsSignInHandle, CommonsSignInStatus, CommonsSignInPurpose, CommonsOAuthContext, CommonsApprovalInfo, CommonsApprovalSubjectAccount, CommonsSignInActionResult, CommonsOAuthFinalizeResult, CommonsDeliveryResult, } from './mixins/OxyServices.auth';
|
|
30
30
|
export type { PushTokenPlatform, RegisterPushTokenInput, } from './mixins/OxyServices.notifications';
|
|
31
31
|
export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
|
|
@@ -116,12 +116,17 @@ export type { SocketIOFactory, MinimalSocket } from './session/socketLoader';
|
|
|
116
116
|
export { createSessionClientHost } from './session/sessionClientHost';
|
|
117
117
|
export { createSessionClient } from './session/createSessionClient';
|
|
118
118
|
export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountIdsOf, } from './session/projectSessionState';
|
|
119
|
-
export {
|
|
120
|
-
export type {
|
|
119
|
+
export { canActivateContext, directoryDisplayName, directoryHandle, projectDevicePrincipals, resolveActiveContext, resolveDeviceContext, } from './session/deviceDirectory';
|
|
120
|
+
export type { DeviceContext, DeviceContextActor, DeviceContextSubject, DevicePrincipalGroup, } from './session/deviceDirectory';
|
|
121
|
+
export { buildSwitcherRows, showsPrincipalHeaders } from './session/deviceSwitcherRows';
|
|
122
|
+
export type { ResolveAvatarUrl, SwitcherContextRow, SwitcherPrincipalRow, } from './session/deviceSwitcherRows';
|
|
123
|
+
export { isSwitchTargetAccount, canSwitchIntoAccount, } from './session/accountSwitchTargets';
|
|
121
124
|
export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController';
|
|
122
125
|
export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, PopupWindowHandle, SignInFlowPhase, SignInFlowState, SignInProgress, } from './session/accountDialogController';
|
|
123
126
|
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './session/authStateStore';
|
|
124
127
|
export type { PersistedAuthState, AuthStateStore, NativeKeyValueStorage, } from './session/authStateStore';
|
|
128
|
+
export { createSharedMirroringAuthStateStore, decideSharedDeviceJoin, decideSharedDevicePublish, normalizeSharedDeviceSessionRead, publishProvenDeviceCredential, readLocalDeviceCredential, } from './session/sharedDeviceCredential';
|
|
129
|
+
export type { SharedDeviceCredential, SharedDeviceCredentialRead, SharedDeviceCredentialStore, SharedDeviceJoinDecision, SharedDeviceJoinSkipReason, SharedDevicePublishDecision, SharedDevicePublishOutcome, SharedDevicePublishSkipReason, } from './session/sharedDeviceCredential';
|
|
125
130
|
export { createWebIdentityPinStore, createNativeIdentityPinStore, createMemoryIdentityPinStore, identityPinMatches, IDENTITY_PIN_STORAGE_KEY, } from './session/identityPin';
|
|
126
131
|
export type { IdentityPin, IdentityPinStore } from './session/identityPin';
|
|
127
132
|
export { resolveIdentityPin, establishIdentitySession, } from './session/identitySession';
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Supports password-based login (email/username) and public key challenge-response.
|
|
5
5
|
*/
|
|
6
6
|
import type { User } from '../models/interfaces';
|
|
7
|
-
import type { LoginResult,
|
|
7
|
+
import type { LoginResult, CommonsDenyReason } from '@oxyhq/contracts';
|
|
8
8
|
import type { SessionLoginResponse } from '../models/session';
|
|
9
9
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
10
10
|
import type { PublicApplication } from './OxyServices.connectedApps';
|
|
@@ -39,6 +39,41 @@ export interface OAuthUserInfoResponse {
|
|
|
39
39
|
name?: string;
|
|
40
40
|
picture?: string;
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* The session an OAuth authorization-code exchange yields.
|
|
44
|
+
*
|
|
45
|
+
* Deliberately NOT `LoginSessionResult`. That type mirrors the API's
|
|
46
|
+
* `buildSessionAuthResponse`, which every FIRST-PARTY sign-in lane emits, and it
|
|
47
|
+
* requires `deviceId` because those lanes always join the origin's DeviceSession.
|
|
48
|
+
* `POST /auth/oauth/token` is the RFC 6749 token endpoint and serves third
|
|
49
|
+
* parties, whose grant is deliberately ISOLATED: an untrusted application must be
|
|
50
|
+
* able to receive a session carrying NO DeviceSession credential at all.
|
|
51
|
+
*
|
|
52
|
+
* Both device fields are therefore optional here, and a response omitting them is
|
|
53
|
+
* a well-formed device-less grant rather than a malformed payload. What that
|
|
54
|
+
* costs the session is spelled out on `exchangeOAuthCode` below.
|
|
55
|
+
*/
|
|
56
|
+
export interface OAuthTokenExchangeResult {
|
|
57
|
+
sessionId: string;
|
|
58
|
+
/** ISO-8601 expiry of {@link accessToken}, derived from RFC 6749 `expires_in`. */
|
|
59
|
+
expiresAt: string;
|
|
60
|
+
accessToken?: string;
|
|
61
|
+
/**
|
|
62
|
+
* The DeviceSession this grant joined, when the server issued one. ABSENT for
|
|
63
|
+
* an isolated third-party grant — never assume a string.
|
|
64
|
+
*/
|
|
65
|
+
deviceId?: string;
|
|
66
|
+
/**
|
|
67
|
+
* The zero-cookie mint credential for {@link deviceId}. Present only alongside
|
|
68
|
+
* it; absent for an isolated third-party grant.
|
|
69
|
+
*/
|
|
70
|
+
deviceSecret?: string;
|
|
71
|
+
user: {
|
|
72
|
+
id: string;
|
|
73
|
+
username?: string;
|
|
74
|
+
avatar?: string;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
42
77
|
/**
|
|
43
78
|
* How a "Sign in with Oxy" request finalizes once the approver authorizes it.
|
|
44
79
|
*
|
|
@@ -793,13 +828,30 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
793
828
|
* response this method used before were an Oxy invention no OAuth library
|
|
794
829
|
* could interoperate with; the endpoint no longer accepts them. The method's
|
|
795
830
|
* OWN signature is unchanged, so callers are unaffected.
|
|
831
|
+
*
|
|
832
|
+
* `deviceId` + `deviceSecret` are OPTIONAL and their absence is a valid
|
|
833
|
+
* outcome, not an error. A third-party grant is meant to be isolated from the
|
|
834
|
+
* browser's shared DeviceSession, so the token endpoint must be free to return
|
|
835
|
+
* no device credential at all — the guard that used to require the pair made
|
|
836
|
+
* that omission unshippable, since it turned every third-party sign-in through
|
|
837
|
+
* the SDK into a silent `exchange-failed`.
|
|
838
|
+
*
|
|
839
|
+
* The cost is real and deliberate: a DEVICE-LESS session cannot use the
|
|
840
|
+
* zero-cookie mint lane (`POST /session/device/token`), because that lane's
|
|
841
|
+
* whole proof is possession of a `deviceSecret`. Its lifetime is therefore the
|
|
842
|
+
* access token itself — nothing persists a restore credential, the cold boot's
|
|
843
|
+
* `device-secret-mint` step reports `no-secret` and skips, and the refresh
|
|
844
|
+
* scheduler has nothing to re-mint from. When the token expires the session
|
|
845
|
+
* ends LOUDLY: the 401 lane clears the tokens and the provider resolves signed
|
|
846
|
+
* out, so the app can run the OAuth flow again. It never degrades into a
|
|
847
|
+
* session that looks alive and cannot refresh.
|
|
796
848
|
*/
|
|
797
849
|
exchangeOAuthCode(params: {
|
|
798
850
|
code: string;
|
|
799
851
|
clientId: string;
|
|
800
852
|
redirectUri: string;
|
|
801
853
|
codeVerifier: string;
|
|
802
|
-
}): Promise<
|
|
854
|
+
}): Promise<OAuthTokenExchangeResult>;
|
|
803
855
|
/**
|
|
804
856
|
* Fetch OpenID Connect userinfo for the current bearer (`GET /auth/oauth/userinfo`).
|
|
805
857
|
* The response is a flat JSON document — no `{ data }` wrapper.
|
|
@@ -850,7 +902,27 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
850
902
|
handleError(error: unknown): Error;
|
|
851
903
|
healthCheck(): Promise<{
|
|
852
904
|
status: string;
|
|
853
|
-
users
|
|
905
|
+
users
|
|
906
|
+
/**
|
|
907
|
+
* @internal Narrow an untrusted delivery-progress timestamp from the status
|
|
908
|
+
* response.
|
|
909
|
+
*
|
|
910
|
+
* Returns the ISO-8601 string unchanged when it is a real, parseable instant,
|
|
911
|
+
* and `null` for everything else — absent (an older API that has no delivery
|
|
912
|
+
* progress at all), empty, non-string, or unparseable. Progress is advisory, so
|
|
913
|
+
* degrading to "no progress yet" is always safe; surfacing a garbage timestamp
|
|
914
|
+
* to the waiting UI is not.
|
|
915
|
+
*/
|
|
916
|
+
? /**
|
|
917
|
+
* @internal Narrow an untrusted delivery-progress timestamp from the status
|
|
918
|
+
* response.
|
|
919
|
+
*
|
|
920
|
+
* Returns the ISO-8601 string unchanged when it is a real, parseable instant,
|
|
921
|
+
* and `null` for everything else — absent (an older API that has no delivery
|
|
922
|
+
* progress at all), empty, non-string, or unparseable. Progress is advisory, so
|
|
923
|
+
* degrading to "no progress yet" is always safe; surfacing a garbage timestamp
|
|
924
|
+
* to the waiting UI is not.
|
|
925
|
+
*/: number;
|
|
854
926
|
timestamp?: string;
|
|
855
927
|
[key: string]: any;
|
|
856
928
|
}>;
|
|
@@ -13,6 +13,17 @@ export interface ClientSession {
|
|
|
13
13
|
* account-chooser ordering, not for any token-refresh mechanism.
|
|
14
14
|
*/
|
|
15
15
|
authuser?: number;
|
|
16
|
+
/**
|
|
17
|
+
* The HUMAN operating this account, when it is a delegated session — the
|
|
18
|
+
* audit actor behind "The Oxy Collective". Absent when the session belongs to
|
|
19
|
+
* the account itself.
|
|
20
|
+
*
|
|
21
|
+
* The flat wire shape has carried it since the multi-account model shipped and
|
|
22
|
+
* nothing read it, so an operated org rendered exactly like a directly
|
|
23
|
+
* signed-in one. `SessionClient.getActiveContext()` is the richer answer
|
|
24
|
+
* (ADR 0002); this is the same fact on the compatibility lane.
|
|
25
|
+
*/
|
|
26
|
+
operatedByUserId?: string;
|
|
16
27
|
}
|
|
17
28
|
export interface StorageKeys {
|
|
18
29
|
sessions: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type DeviceSessionState } from '@oxyhq/contracts';
|
|
1
|
+
import { type DeviceDirectory, type DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import { type DeviceContext } from './deviceDirectory';
|
|
2
3
|
import type { MinimalSocket, SocketIOFactory } from './socketLoader';
|
|
3
4
|
export interface TokenTransport {
|
|
4
5
|
/** Ensure this app holds a per-domain access token for state.activeAccountId (mint via the persisted refresh family / shared keychain). Best-effort. */
|
|
@@ -84,11 +85,25 @@ export interface SessionClientOptions {
|
|
|
84
85
|
getPinnedAccountId?: () => string | null;
|
|
85
86
|
}
|
|
86
87
|
type StateListener = (state: DeviceSessionState | null) => void;
|
|
88
|
+
type DirectoryListener = (directory: DeviceDirectory | null) => void;
|
|
87
89
|
export declare class SessionClient {
|
|
88
90
|
protected readonly host: SessionClientHost;
|
|
89
91
|
protected readonly options: SessionClientOptions;
|
|
90
92
|
private state;
|
|
93
|
+
/**
|
|
94
|
+
* The device DIRECTORY (ADR 0002) — principals, the contexts each may act as,
|
|
95
|
+
* and which context is active. Held BESIDE `state` rather than replacing it:
|
|
96
|
+
* `state` is the flat compatibility projection every app renders until Phase 7
|
|
97
|
+
* moves it, and the two describe the same device at the same `revision`.
|
|
98
|
+
*
|
|
99
|
+
* `null` until something asks for it. A client that never calls
|
|
100
|
+
* {@link refreshDirectory} has no consumer for a directory and never pays for
|
|
101
|
+
* the round trip — which is also what keeps the whole account lane's request
|
|
102
|
+
* count unchanged.
|
|
103
|
+
*/
|
|
104
|
+
private directory;
|
|
91
105
|
private readonly listeners;
|
|
106
|
+
private readonly directoryListeners;
|
|
92
107
|
protected socket: MinimalSocket | null;
|
|
93
108
|
private tokenUnsub;
|
|
94
109
|
private started;
|
|
@@ -100,6 +115,21 @@ export declare class SessionClient {
|
|
|
100
115
|
private readonly boundServerEvents;
|
|
101
116
|
constructor(host: SessionClientHost, options?: SessionClientOptions);
|
|
102
117
|
getState(): DeviceSessionState | null;
|
|
118
|
+
/**
|
|
119
|
+
* The device directory, or `null` when this client has never read one.
|
|
120
|
+
* Populated by {@link refreshDirectory} / {@link activateContext} and kept
|
|
121
|
+
* fresh from there on.
|
|
122
|
+
*/
|
|
123
|
+
getDirectory(): DeviceDirectory | null;
|
|
124
|
+
/**
|
|
125
|
+
* The active `principal acting as account` pair, with the actor and the
|
|
126
|
+
* subject kept apart. `null` when no directory has been read, or when the
|
|
127
|
+
* device genuinely has no active context.
|
|
128
|
+
*
|
|
129
|
+
* This is the answer `getState()` cannot give: `activeAccountId` names the
|
|
130
|
+
* subject and says nothing about whose authentication is behind it.
|
|
131
|
+
*/
|
|
132
|
+
getActiveContext(): DeviceContext | null;
|
|
103
133
|
/**
|
|
104
134
|
* The account this client's bearer is pinned to, or `null` when it follows the
|
|
105
135
|
* device's active account (the default). Resolvers are expected to be a plain
|
|
@@ -108,6 +138,14 @@ export declare class SessionClient {
|
|
|
108
138
|
*/
|
|
109
139
|
private pinnedAccountId;
|
|
110
140
|
subscribe(listener: StateListener): () => void;
|
|
141
|
+
/**
|
|
142
|
+
* Subscribe to the directory half. Fires from the SAME {@link notify} as
|
|
143
|
+
* {@link subscribe}, so the flat state and the directory are never published
|
|
144
|
+
* at two different points of the ordering sequence — a directory subscriber
|
|
145
|
+
* and a state subscriber woken by one transition always see the same device
|
|
146
|
+
* revision under the same bearer.
|
|
147
|
+
*/
|
|
148
|
+
subscribeDirectory(listener: DirectoryListener): () => void;
|
|
111
149
|
/**
|
|
112
150
|
* Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
|
|
113
151
|
* Listeners survive reconnects and socket re-creation; the returned function
|
|
@@ -143,13 +181,176 @@ export declare class SessionClient {
|
|
|
143
181
|
* account that is no longer active.
|
|
144
182
|
*/
|
|
145
183
|
private applySync;
|
|
184
|
+
/**
|
|
185
|
+
* The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
|
|
186
|
+
* sync. Split out so the context-aware removal lane — whose response is a
|
|
187
|
+
* different wire shape and therefore a different parse — reuses this ordering
|
|
188
|
+
* verbatim instead of re-deriving it. Two implementations of
|
|
189
|
+
* "plant before notify" is two chances to get it wrong once.
|
|
190
|
+
*
|
|
191
|
+
* Returns whether `applyState` applied, so a caller holding a second half
|
|
192
|
+
* (the directory) can decide whether anything still needs publishing.
|
|
193
|
+
*/
|
|
194
|
+
private commitSync;
|
|
195
|
+
/**
|
|
196
|
+
* Validate + last-writer-wins, `deviceId`-SCOPED exactly as {@link applyState}
|
|
197
|
+
* is for the flat half: a directory belonging to a DIFFERENT device resets the
|
|
198
|
+
* baseline and is accepted at any revision, so a freshly-converged device
|
|
199
|
+
* cannot lose to a retired device's higher number.
|
|
200
|
+
*
|
|
201
|
+
* The comparison itself is deliberately WEAKER than the flat state's. There it
|
|
202
|
+
* is `revision <= current` — correct, because a `DeviceSessionState` arrives
|
|
203
|
+
* out of band over a socket, so a straggler can genuinely land after a newer
|
|
204
|
+
* one. A directory only ever arrives as the response to a request THIS client
|
|
205
|
+
* just made, so the newest response is the freshest answer and only a strictly
|
|
206
|
+
* LOWER revision can be a straggler (two GETs racing).
|
|
207
|
+
*
|
|
208
|
+
* Equal-revision reads are not redundant, and rejecting them was a bug: the
|
|
209
|
+
* directory includes rows projected from the account GRAPH, and the server
|
|
210
|
+
* materializes a context for every account a principal may act as WITHOUT
|
|
211
|
+
* bumping `revision` — deliberately, since `revision` tracks what the device
|
|
212
|
+
* holds and must never advance on a read. So a newly-granted `account:act_as`,
|
|
213
|
+
* and a removed-then-rematerialized context under its NEW id, both appear at
|
|
214
|
+
* an unchanged revision. Under `<=` neither would ever be seen until some
|
|
215
|
+
* unrelated device mutation happened to move the number.
|
|
216
|
+
*
|
|
217
|
+
* Notifies nothing. Every caller decides where in its own ordering sequence
|
|
218
|
+
* the publish belongs.
|
|
219
|
+
*/
|
|
220
|
+
private applyDirectory;
|
|
221
|
+
/** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
|
|
222
|
+
private fetchDirectory;
|
|
223
|
+
/**
|
|
224
|
+
* Re-read the directory when the flat state has moved past it, returning the
|
|
225
|
+
* in-flight work so the caller can hold its notify until both halves describe
|
|
226
|
+
* the same revision.
|
|
227
|
+
*
|
|
228
|
+
* `null` — meaning "nothing to settle, stay synchronous" — when this client
|
|
229
|
+
* holds no directory (nobody reads one), when the directory is already at or
|
|
230
|
+
* ahead of the state, or when there is no bearer to make the call with.
|
|
231
|
+
* Never rejects: a failed refresh leaves the previous directory in place and
|
|
232
|
+
* the next transition tries again; it must not swallow the flat state's
|
|
233
|
+
* notify.
|
|
234
|
+
*/
|
|
235
|
+
private settleDirectory;
|
|
236
|
+
/**
|
|
237
|
+
* The highest device revision this client currently knows for `deviceId`,
|
|
238
|
+
* across BOTH halves, or `null` when it knows nothing about that device.
|
|
239
|
+
*
|
|
240
|
+
* Used to read the server's `changed` flag, which
|
|
241
|
+
* `POST /session/device/activate` deliberately does not carry: the revision
|
|
242
|
+
* already says whether the device moved, and a second field saying the same
|
|
243
|
+
* thing is a second field that can disagree with the first.
|
|
244
|
+
*/
|
|
245
|
+
private knownRevisionFor;
|
|
246
|
+
/**
|
|
247
|
+
* Plant the bearer `POST /session/device/activate` returned for the newly
|
|
248
|
+
* active context, under the same guards {@link applyState} applies to a
|
|
249
|
+
* sync-supplied `activeToken`: never for a null active context, never a
|
|
250
|
+
* foreign account's token while pinned, and never a redundant re-plant of the
|
|
251
|
+
* token already held.
|
|
252
|
+
*
|
|
253
|
+
* `activeToken: null` is not an error — it is an identity-pinned client, or a
|
|
254
|
+
* caller whose application is not entitled to a bearer for the new context.
|
|
255
|
+
*/
|
|
256
|
+
private plantActiveContextToken;
|
|
257
|
+
/**
|
|
258
|
+
* Bring the FLAT projection back in step after a context activation.
|
|
259
|
+
*
|
|
260
|
+
* The activation response answers with the directory and a bearer and
|
|
261
|
+
* deliberately not with `DeviceSessionState` (ADR 0002) — but every app that
|
|
262
|
+
* has not moved to the directory still renders from `getState()`, and leaving
|
|
263
|
+
* it a revision behind would show the PREVIOUS subject under the new
|
|
264
|
+
* subject's bearer. So it is settled BEFORE the notify, not after.
|
|
265
|
+
*
|
|
266
|
+
* This is also what converges the bearer when the activation returned no
|
|
267
|
+
* token: `GET /session/device/state` mints one for the active account, and
|
|
268
|
+
* `applyState`'s own mint-before-notify gate holds its notify until it lands.
|
|
269
|
+
*
|
|
270
|
+
* Non-fatal on failure — the directory is applied and (usually) the bearer is
|
|
271
|
+
* planted; the socket push or the next bootstrap catches the flat half up. A
|
|
272
|
+
* network blip must not turn a completed activation into a thrown error.
|
|
273
|
+
*/
|
|
274
|
+
private reconcileFlatState;
|
|
146
275
|
bootstrap(): Promise<void>;
|
|
276
|
+
/**
|
|
277
|
+
* Read `GET /session/device/directory` and publish it.
|
|
278
|
+
*
|
|
279
|
+
* Calling this is what opts a client into the directory: from here on every
|
|
280
|
+
* applied device state re-reads it (see {@link settleDirectory}), so the two
|
|
281
|
+
* halves stay at one revision without the caller polling.
|
|
282
|
+
*/
|
|
283
|
+
refreshDirectory(): Promise<void>;
|
|
284
|
+
/**
|
|
285
|
+
* `POST /session/device/activate` — make one `principal acting as account`
|
|
286
|
+
* context active (ADR 0002).
|
|
287
|
+
*
|
|
288
|
+
* The body is `{ contextId }` and nothing else: an `accountId` cannot name
|
|
289
|
+
* what to activate on a device where two people can both reach the same
|
|
290
|
+
* organization, and the server refuses a body carrying one rather than
|
|
291
|
+
* guessing inside an authorization path.
|
|
292
|
+
*
|
|
293
|
+
* The sequence is the ADR's ordering invariant, in order — commit the bearer
|
|
294
|
+
* for the new context, publish the new snapshot, notify — with the flat
|
|
295
|
+
* projection reconciled in the middle so no consumer can observe the two
|
|
296
|
+
* halves disagreeing.
|
|
297
|
+
*
|
|
298
|
+
* An IDEMPOTENT activation (the target was already active) moves no revision,
|
|
299
|
+
* so it reconciles nothing and wakes no sibling tab, mirroring the server's
|
|
300
|
+
* "bumps nothing and broadcasts nothing". `switchAccount` remains the
|
|
301
|
+
* compatibility path for callers still keyed on account ids.
|
|
302
|
+
*/
|
|
303
|
+
activateContext(contextId: string): Promise<void>;
|
|
147
304
|
switchAccount(accountId: string): Promise<void>;
|
|
305
|
+
/**
|
|
306
|
+
* The FLAT removal meanings, unchanged: `{ accountId }` removes that account
|
|
307
|
+
* however it is reached — plus the operator cascade — and `{ all: true }`
|
|
308
|
+
* removes the whole device including its credentials.
|
|
309
|
+
*
|
|
310
|
+
* `{ accountId }` is deliberately still account-grained. On a device holding
|
|
311
|
+
* two people it removes BOTH of their routes to that account, which is the
|
|
312
|
+
* right meaning for "sign this account out of this device" and the wrong one
|
|
313
|
+
* for "this person is done here" — see {@link signOutContext} and
|
|
314
|
+
* {@link signOutPrincipal} for the two that can tell those apart.
|
|
315
|
+
*/
|
|
148
316
|
signOut(target: {
|
|
149
317
|
accountId: string;
|
|
150
318
|
} | {
|
|
151
319
|
all: true;
|
|
152
320
|
}): Promise<void>;
|
|
321
|
+
/**
|
|
322
|
+
* Remove ONE `principal → account` pair, and only that pair.
|
|
323
|
+
*
|
|
324
|
+
* Never the account across the device: the same organization reached through
|
|
325
|
+
* a second person is a different session, a different audit actor and a
|
|
326
|
+
* different revocation path, and it stays. That distinction is unreachable
|
|
327
|
+
* through {@link signOut}, whose `accountId` cannot name which route to drop.
|
|
328
|
+
*
|
|
329
|
+
* Removal is not permanent while the membership lives — the server offers the
|
|
330
|
+
* pair again on the next directory read, as `onDevice: false` under a NEW id.
|
|
331
|
+
*/
|
|
332
|
+
signOutContext(contextId: string): Promise<void>;
|
|
333
|
+
/**
|
|
334
|
+
* Remove ONE PERSON and every context they reach — and nobody else's,
|
|
335
|
+
* including when another principal independently operates the same account.
|
|
336
|
+
*/
|
|
337
|
+
signOutPrincipal(principalId: string): Promise<void>;
|
|
338
|
+
/**
|
|
339
|
+
* The shared apply path for both context-aware removals.
|
|
340
|
+
*
|
|
341
|
+
* The response is `{directory, state, activeToken}` — its own contract, never
|
|
342
|
+
* `deviceSessionSyncSchema`, which would strip the directory silently. Both
|
|
343
|
+
* halves move in one server transition (a removal elects a replacement active
|
|
344
|
+
* context), so both are applied before anything is published: the directory
|
|
345
|
+
* first, so the flat apply's own `settleDirectory` sees a current directory
|
|
346
|
+
* and does not issue a redundant `GET /session/device/directory` for the
|
|
347
|
+
* revision already in hand.
|
|
348
|
+
*
|
|
349
|
+
* Token-before-notify is `commitSync`'s, reused verbatim rather than
|
|
350
|
+
* re-derived — including the equal-revision plant when a socket push already
|
|
351
|
+
* applied this revision.
|
|
352
|
+
*/
|
|
353
|
+
private removeFromDevice;
|
|
153
354
|
addCurrentAccount(): Promise<void>;
|
|
154
355
|
/**
|
|
155
356
|
* Register the just-signed-in account into the device set AND make it the
|