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