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