@oxyhq/core 7.1.0 → 7.1.1
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/session/accountDialogController.js +87 -2
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/session/accountDialogController.js +87 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/session/accountDialogController.d.ts +27 -0
- package/package.json +1 -1
- package/src/session/__tests__/accountDialogController.test.ts +123 -0
- package/src/session/accountDialogController.ts +89 -2
|
@@ -56,6 +56,9 @@ export class AccountDialogController {
|
|
|
56
56
|
this.pollTimer = null;
|
|
57
57
|
// --- Store plumbing ---
|
|
58
58
|
this.unsubscribeSession = null;
|
|
59
|
+
this.unsubscribeTokens = null;
|
|
60
|
+
/** Last-observed SDK auth readiness (a planted bearer). Drives the fetch edge. */
|
|
61
|
+
this.authed = false;
|
|
59
62
|
this.started = false;
|
|
60
63
|
this.refreshSeq = 0;
|
|
61
64
|
this.oxyServices = options.oxyServices;
|
|
@@ -95,13 +98,28 @@ export class AccountDialogController {
|
|
|
95
98
|
if (this.started)
|
|
96
99
|
return;
|
|
97
100
|
this.started = true;
|
|
101
|
+
this.authed = this.isAuthenticated();
|
|
98
102
|
this.unsubscribeSession = this.sessionClient.subscribe(() => {
|
|
99
103
|
// A device-state change (switch / sign-out / sibling sign-in) can add or
|
|
100
|
-
// remove accounts — re-project immediately,
|
|
101
|
-
// account ids appeared.
|
|
104
|
+
// remove accounts — re-project immediately, refetch profiles when new
|
|
105
|
+
// account ids appeared, and reconcile the auth-readiness edge.
|
|
102
106
|
this.emit();
|
|
103
107
|
void this.ensureProfiles();
|
|
108
|
+
this.reconcileAuth();
|
|
104
109
|
});
|
|
110
|
+
// The access token is planted AFTER `SessionClient.applyState` fires its
|
|
111
|
+
// subscription (`applySync` calls `setTokens` only once `applyState`/notify
|
|
112
|
+
// has returned; `ensureActiveToken` plants it async later), so the
|
|
113
|
+
// device-state subscription alone cannot observe the signed-out → signed-in
|
|
114
|
+
// edge. Observe the SDK-canonical readiness signal directly — a change to
|
|
115
|
+
// `oxyServices.getAccessToken()`, the `hasAccessToken` term of
|
|
116
|
+
// `OxyContext.canUsePrivateApi`.
|
|
117
|
+
this.unsubscribeTokens = this.oxyServices.onTokensChanged(() => {
|
|
118
|
+
this.reconcileAuth();
|
|
119
|
+
});
|
|
120
|
+
// Initial projection is device-only. `refresh()` fetches the graph IFF a
|
|
121
|
+
// bearer is already planted (warm start); when signed out (cold boot before
|
|
122
|
+
// restore) it re-projects from device state and makes NO private call.
|
|
105
123
|
void this.refresh();
|
|
106
124
|
}
|
|
107
125
|
/**
|
|
@@ -114,10 +132,58 @@ export class AccountDialogController {
|
|
|
114
132
|
this.unsubscribeSession();
|
|
115
133
|
this.unsubscribeSession = null;
|
|
116
134
|
}
|
|
135
|
+
if (this.unsubscribeTokens) {
|
|
136
|
+
this.unsubscribeTokens();
|
|
137
|
+
this.unsubscribeTokens = null;
|
|
138
|
+
}
|
|
117
139
|
this.clearPollTimer();
|
|
118
140
|
this.listeners.clear();
|
|
119
141
|
}
|
|
120
142
|
// =========================================================================
|
|
143
|
+
// Auth readiness (SDK-canonical — mirrors OxyContext.canUsePrivateApi)
|
|
144
|
+
// =========================================================================
|
|
145
|
+
/**
|
|
146
|
+
* Whether a PRIVATE endpoint may be called right now. Mirrors the
|
|
147
|
+
* `hasAccessToken` term of `OxyContext.canUsePrivateApi`
|
|
148
|
+
* (`authResolved && isAuthenticated && tokenReady && hasAccessToken`, where
|
|
149
|
+
* `hasAccessToken = Boolean(oxyServices.getAccessToken())`): a planted bearer
|
|
150
|
+
* is the only term that decides whether a request carries auth — the other
|
|
151
|
+
* three are provider render-lifecycle gates with no headless equivalent.
|
|
152
|
+
*
|
|
153
|
+
* `listAccounts()` (`GET /accounts`) and `getUsersByIds()`
|
|
154
|
+
* (`POST /users/by-ids`) are private; calling either before cold-boot restore
|
|
155
|
+
* plants the token 401s → `HttpService` clears the bearer + emits
|
|
156
|
+
* `onTokensChanged(null)` → the app signs out. Every graph/profile fetch gates
|
|
157
|
+
* on this.
|
|
158
|
+
*/
|
|
159
|
+
isAuthenticated() {
|
|
160
|
+
return Boolean(this.oxyServices.getAccessToken());
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Reconcile the account graph against the current auth-readiness edge. On the
|
|
164
|
+
* signed-out → signed-in edge fetch the graph ONCE; on signed-in → signed-out
|
|
165
|
+
* drop it and re-project device-only. A no-op when readiness is unchanged, so
|
|
166
|
+
* a burst of token events / device pushes cannot restart the fetch — and a
|
|
167
|
+
* failed `listAccounts()` never flips the edge, so it cannot re-trigger itself
|
|
168
|
+
* (no retry storm).
|
|
169
|
+
*/
|
|
170
|
+
reconcileAuth() {
|
|
171
|
+
const authed = this.isAuthenticated();
|
|
172
|
+
if (authed === this.authed)
|
|
173
|
+
return;
|
|
174
|
+
this.authed = authed;
|
|
175
|
+
if (authed) {
|
|
176
|
+
void this.refresh();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// Signed out: the graph is no longer fetchable/switchable — drop it and
|
|
180
|
+
// re-project from the device session set alone.
|
|
181
|
+
this.graph = [];
|
|
182
|
+
this.error = null;
|
|
183
|
+
this.loading = false;
|
|
184
|
+
this.emit();
|
|
185
|
+
}
|
|
186
|
+
// =========================================================================
|
|
121
187
|
// View actions
|
|
122
188
|
// =========================================================================
|
|
123
189
|
/** Set the dialog view directly. */
|
|
@@ -146,6 +212,17 @@ export class AccountDialogController {
|
|
|
146
212
|
*/
|
|
147
213
|
async refresh() {
|
|
148
214
|
const seq = ++this.refreshSeq;
|
|
215
|
+
// Never hit the private `listAccounts()` while signed out: at cold boot the
|
|
216
|
+
// bearer is not planted yet, so the call 401s → `HttpService` clears the
|
|
217
|
+
// token and signs the user out. Re-project from the device session set alone
|
|
218
|
+
// (`projectSwitchableAccounts` works from `SessionClient` state) and stop.
|
|
219
|
+
if (!this.isAuthenticated()) {
|
|
220
|
+
this.graph = [];
|
|
221
|
+
this.loading = false;
|
|
222
|
+
this.error = null;
|
|
223
|
+
this.emit();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
149
226
|
const hadAccounts = this.snapshot.accounts.length > 0;
|
|
150
227
|
this.loading = !hadAccounts;
|
|
151
228
|
this.error = null;
|
|
@@ -175,6 +252,9 @@ export class AccountDialogController {
|
|
|
175
252
|
* subscription so a newly-added device account gets a name/avatar.
|
|
176
253
|
*/
|
|
177
254
|
async ensureProfiles() {
|
|
255
|
+
// `getUsersByIds` is private — skip the whole path while signed out.
|
|
256
|
+
if (!this.isAuthenticated())
|
|
257
|
+
return;
|
|
178
258
|
const ids = switchableAccountIds(this.sessionClient.getState(), this.graph);
|
|
179
259
|
if (ids.every((id) => this.profilesById.has(id)))
|
|
180
260
|
return;
|
|
@@ -182,6 +262,11 @@ export class AccountDialogController {
|
|
|
182
262
|
this.emit();
|
|
183
263
|
}
|
|
184
264
|
async loadProfiles(seq) {
|
|
265
|
+
// `getUsersByIds` (`POST /users/by-ids`) is a private call — never issue it
|
|
266
|
+
// while signed out (the 401 → sign-out cascade). Callers already gate; this
|
|
267
|
+
// guards the network chokepoint too (e.g. the token was cleared mid-refresh).
|
|
268
|
+
if (!this.isAuthenticated())
|
|
269
|
+
return;
|
|
185
270
|
const ids = switchableAccountIds(this.sessionClient.getState(), this.graph);
|
|
186
271
|
if (ids.length === 0)
|
|
187
272
|
return;
|