@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.
@@ -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, and refetch profiles when new
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;