@oxyhq/core 12.11.0 → 12.11.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.
@@ -89,7 +89,15 @@ export declare class HttpService {
89
89
  private logger;
90
90
  private config;
91
91
  private tokenRefreshPromise;
92
- private tokenRefreshCooldownUntil;
92
+ /**
93
+ * Epoch ms of the last FAILED refresh (0 = none since the last success). The
94
+ * post-failure cooldown is measured from here; its length depends on whether
95
+ * the current token is still valid ({@link TOKEN_REFRESH_COOLDOWN_MS}) or
96
+ * already expired ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}), so an expired
97
+ * token recovers promptly the instant it crosses `exp` — without storing a
98
+ * fixed deadline that could not shrink once the token expired mid-cooldown.
99
+ */
100
+ private lastRefreshFailureAt;
93
101
  private authRefreshHandler;
94
102
  private accessTokenProvider;
95
103
  private deviceSecretMintInFlight;
@@ -224,6 +232,15 @@ export declare class HttpService {
224
232
  */
225
233
  private getAuthHeader;
226
234
  refreshAccessToken(reason: AuthRefreshReason): Promise<string | null>;
235
+ /**
236
+ * Whether the CURRENT stored access token is already past its `exp`. Drives
237
+ * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
238
+ * a still-valid (near-expiry) token can wait out the full cooldown, but an
239
+ * expired one must re-mint promptly. Returns `false` for an absent or
240
+ * opaque/no-`exp` token — no proof it is expired, so keep the conservative
241
+ * (longer) cooldown and avoid an unnecessary retry loop.
242
+ */
243
+ private isAccessTokenExpired;
227
244
  /**
228
245
  * PROCESS-WIDE single-flight for the rotating device-secret mint
229
246
  * (`POST /session/device/token`).
@@ -55,6 +55,8 @@ export interface ViewerGraph {
55
55
  mutualIds: string[];
56
56
  /** Accounts the viewer has blocked (bounded). */
57
57
  blockedIds: string[];
58
+ /** Accounts the viewer has restricted (bounded). */
59
+ restrictedIds: string[];
58
60
  }
59
61
  /** Per-user outcome returned by `POST /users/unfollow/bulk`. */
60
62
  export interface BulkUnfollowEntry {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "12.11.0",
3
+ "version": "12.11.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -157,7 +157,7 @@
157
157
  "expo-crypto": "~56.0.3",
158
158
  "expo-secure-store": "~56.0.4",
159
159
  "express": "^4.22.2",
160
- "express-rate-limit": "^7.5.0",
160
+ "express-rate-limit": "^8.6.0",
161
161
  "regexpu-core": "^6.4.0",
162
162
  "release-it": "^19.0.6",
163
163
  "typescript": "^5.9.2"
@@ -143,12 +143,32 @@ const CSRF_FETCH_RETRY_DELAY_MS = 500;
143
143
 
144
144
  /**
145
145
  * Cooldown (ms) applied after a failed access-token refresh before another
146
- * refresh is attempted. Prevents a refresh storm (and server hammering) when
146
+ * refresh is attempted while the CURRENT token is still valid (a proactive,
147
+ * near-expiry refresh). Prevents a refresh storm (and server hammering) when
147
148
  * the auth refresh handler is failing — every in-flight request that
148
- * hits a 401 would otherwise trigger its own refresh.
149
+ * hits a 401 would otherwise trigger its own refresh. A still-valid token can
150
+ * afford to wait this out; the request keeps carrying it in the meantime.
149
151
  */
150
152
  const TOKEN_REFRESH_COOLDOWN_MS = 15000;
151
153
 
154
+ /**
155
+ * Cooldown (ms) applied after a failed refresh when the CURRENT access token is
156
+ * already past its `exp`. Much shorter than {@link TOKEN_REFRESH_COOLDOWN_MS}:
157
+ * an expired token is UNUSABLE, so the client must re-mint as soon as the mint
158
+ * endpoint is reachable again (e.g. a few seconds after an ECS rolling-deploy
159
+ * blip drains/restarts a task) instead of waiting out the full proactive
160
+ * cooldown while every request forwards or omits a stale bearer → server 401.
161
+ *
162
+ * Still NON-ZERO on purpose: it bounds the request-driven retry rate to at most
163
+ * one attempt per this interval so a PROLONGED outage cannot become a tight
164
+ * network storm. Combined with the process-wide single-flight below (concurrent
165
+ * requests coalesce to one in-flight mint) and the refresh handler's own
166
+ * terminal-state handling (a genuinely revoked session clears its device
167
+ * credential and stops issuing network mints), this recovers a transient blip
168
+ * ~15× faster without weakening the storm guard.
169
+ */
170
+ const EXPIRED_TOKEN_REFRESH_COOLDOWN_MS = 1000;
171
+
152
172
  /**
153
173
  * Lead time (seconds) before access-token expiry at which a preflight refresh
154
174
  * is triggered. A token within this window of `exp` is treated as effectively
@@ -247,7 +267,15 @@ export class HttpService {
247
267
  private logger: SimpleLogger;
248
268
  private config: OxyConfig;
249
269
  private tokenRefreshPromise: Promise<string | null> | null = null;
250
- private tokenRefreshCooldownUntil = 0;
270
+ /**
271
+ * Epoch ms of the last FAILED refresh (0 = none since the last success). The
272
+ * post-failure cooldown is measured from here; its length depends on whether
273
+ * the current token is still valid ({@link TOKEN_REFRESH_COOLDOWN_MS}) or
274
+ * already expired ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}), so an expired
275
+ * token recovers promptly the instant it crosses `exp` — without storing a
276
+ * fixed deadline that could not shrink once the token expired mid-cooldown.
277
+ */
278
+ private lastRefreshFailureAt = 0;
251
279
  private authRefreshHandler: AuthRefreshHandler | null = null;
252
280
  private accessTokenProvider: AccessTokenProvider | null = null;
253
281
  private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
@@ -1058,7 +1086,16 @@ export class HttpService {
1058
1086
  return null;
1059
1087
  }
1060
1088
 
1061
- if (Date.now() < this.tokenRefreshCooldownUntil) {
1089
+ // Post-failure cooldown. A genuinely EXPIRED current token uses a much
1090
+ // shorter cooldown than a still-valid (proactive, near-expiry) one: an
1091
+ // expired token is unusable, so re-mint as soon as the endpoint is reachable
1092
+ // again rather than waiting out the full window while requests carry a stale
1093
+ // bearer. Both cooldowns are measured from the last failure, so the moment a
1094
+ // still-valid token crosses `exp` mid-cooldown the shorter window applies.
1095
+ const cooldownMs = this.isAccessTokenExpired()
1096
+ ? EXPIRED_TOKEN_REFRESH_COOLDOWN_MS
1097
+ : TOKEN_REFRESH_COOLDOWN_MS;
1098
+ if (Date.now() - this.lastRefreshFailureAt < cooldownMs) {
1062
1099
  return null;
1063
1100
  }
1064
1101
 
@@ -1066,19 +1103,22 @@ export class HttpService {
1066
1103
  this.tokenRefreshPromise = this.authRefreshHandler(reason)
1067
1104
  .then((newToken) => {
1068
1105
  if (!newToken) {
1069
- this.tokenRefreshCooldownUntil = Date.now() + TOKEN_REFRESH_COOLDOWN_MS;
1106
+ this.lastRefreshFailureAt = Date.now();
1070
1107
  return null;
1071
1108
  }
1072
1109
  if (this.tokenStore.getAccessToken() !== newToken) {
1073
1110
  this.tokenStore.setTokens(newToken);
1074
1111
  this.notifyTokenChange();
1075
1112
  }
1113
+ // A success clears the failure timestamp so the next refresh is never
1114
+ // throttled by a stale cooldown.
1115
+ this.lastRefreshFailureAt = 0;
1076
1116
  this.logger.debug('Token refreshed via the auth refresh handler');
1077
1117
  return newToken;
1078
1118
  })
1079
1119
  .catch((error) => {
1080
1120
  this.logger.warn('Token refresh failed:', error);
1081
- this.tokenRefreshCooldownUntil = Date.now() + TOKEN_REFRESH_COOLDOWN_MS;
1121
+ this.lastRefreshFailureAt = Date.now();
1082
1122
  return null;
1083
1123
  })
1084
1124
  .finally(() => {
@@ -1089,6 +1129,27 @@ export class HttpService {
1089
1129
  return this.tokenRefreshPromise;
1090
1130
  }
1091
1131
 
1132
+ /**
1133
+ * Whether the CURRENT stored access token is already past its `exp`. Drives
1134
+ * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
1135
+ * a still-valid (near-expiry) token can wait out the full cooldown, but an
1136
+ * expired one must re-mint promptly. Returns `false` for an absent or
1137
+ * opaque/no-`exp` token — no proof it is expired, so keep the conservative
1138
+ * (longer) cooldown and avoid an unnecessary retry loop.
1139
+ */
1140
+ private isAccessTokenExpired(): boolean {
1141
+ const token = this.tokenStore.getAccessToken();
1142
+ if (!token) {
1143
+ return false;
1144
+ }
1145
+ try {
1146
+ const decoded = jwtDecode<JwtPayload>(token);
1147
+ return typeof decoded.exp === 'number' && decoded.exp <= Math.floor(Date.now() / 1000);
1148
+ } catch {
1149
+ return false;
1150
+ }
1151
+ }
1152
+
1092
1153
  /**
1093
1154
  * PROCESS-WIDE single-flight for the rotating device-secret mint
1094
1155
  * (`POST /session/device/token`).
@@ -164,6 +164,73 @@ describe('HttpService in-session refresh handler', () => {
164
164
  // The second call is inside the post-failure cooldown → handler not re-run.
165
165
  expect(handlerCalls).toBe(1);
166
166
  });
167
+
168
+ it('lets an EXPIRED token re-mint promptly instead of waiting out the long proactive cooldown', async () => {
169
+ // Regression: an ECS rolling-deploy blip briefly fails a mint; the 15s
170
+ // proactive cooldown then left the client forwarding/omitting a now-expired
171
+ // bearer for up to 15s after the endpoint recovered (Mention /privacy 401s).
172
+ // An already-expired token must recover on the short cooldown instead.
173
+ globalThis.fetch = async () => jsonResponse({ ok: true });
174
+ const nowSpy = jest.spyOn(Date, 'now');
175
+ const T0 = 1_000_000_000_000;
176
+ nowSpy.mockReturnValue(T0);
177
+
178
+ const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
179
+ // Current token is already 10s past exp (exp is in SECONDS).
180
+ http.setTokens(createJwt({ userId: 'u', exp: Math.floor(T0 / 1000) - 10 }));
181
+
182
+ let handlerCalls = 0;
183
+ http.setAuthRefreshHandler(async () => {
184
+ handlerCalls += 1;
185
+ return null;
186
+ });
187
+
188
+ // First attempt fails → records the failure timestamp.
189
+ await http.refreshAccessToken('preflight');
190
+ expect(handlerCalls).toBe(1);
191
+
192
+ // 500ms later: still inside the SHORT expired cooldown → NOT re-run. This is
193
+ // the storm guard — an expired token does not fully bypass the cooldown.
194
+ nowSpy.mockReturnValue(T0 + 500);
195
+ await http.refreshAccessToken('preflight');
196
+ expect(handlerCalls).toBe(1);
197
+
198
+ // 1.5s later: past the short expired cooldown but WELL inside the 15s
199
+ // proactive cooldown → the expired token re-mints promptly.
200
+ nowSpy.mockReturnValue(T0 + 1500);
201
+ await http.refreshAccessToken('preflight');
202
+ expect(handlerCalls).toBe(2);
203
+
204
+ nowSpy.mockRestore();
205
+ });
206
+
207
+ it('keeps the full 15s cooldown for a still-valid (proactive) near-expiry refresh', async () => {
208
+ globalThis.fetch = async () => jsonResponse({ ok: true });
209
+ const nowSpy = jest.spyOn(Date, 'now');
210
+ const T0 = 1_000_000_000_000;
211
+ nowSpy.mockReturnValue(T0);
212
+
213
+ const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
214
+ // Token is still valid for another hour — a proactive refresh, not expired.
215
+ http.setTokens(createJwt({ userId: 'u', exp: Math.floor(T0 / 1000) + 3600 }));
216
+
217
+ let handlerCalls = 0;
218
+ http.setAuthRefreshHandler(async () => {
219
+ handlerCalls += 1;
220
+ return null;
221
+ });
222
+
223
+ await http.refreshAccessToken('preflight');
224
+ expect(handlerCalls).toBe(1);
225
+
226
+ // 1.5s later: past the short expired cooldown, but the token is NOT expired,
227
+ // so the full proactive cooldown still applies → no re-run (no storm).
228
+ nowSpy.mockReturnValue(T0 + 1500);
229
+ await http.refreshAccessToken('preflight');
230
+ expect(handlerCalls).toBe(1);
231
+
232
+ nowSpy.mockRestore();
233
+ });
167
234
  });
168
235
 
169
236
  describe('OxyServices.getAccessTokenExpiry', () => {
@@ -167,6 +167,9 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
167
167
  cache: false,
168
168
  });
169
169
  this.clearCacheEntry('GET:/privacy/restricted');
170
+ // The restriction changed the viewer's graph (`restrictedIds`) — bust the
171
+ // cached consolidated `GET /users/me/graph` so the next read reflects it.
172
+ this.clearCacheEntry('GET:/users/me/graph');
170
173
  return result;
171
174
  } catch (error) {
172
175
  throw this.handleError(error);
@@ -190,6 +193,9 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
190
193
  cache: false,
191
194
  });
192
195
  this.clearCacheEntry('GET:/privacy/restricted');
196
+ // Symmetric to restrictUser: the unrestrict changed the viewer's
197
+ // `restrictedIds`, so bust the consolidated `GET /users/me/graph` cache.
198
+ this.clearCacheEntry('GET:/users/me/graph');
193
199
  return result;
194
200
  } catch (error) {
195
201
  throw this.handleError(error);
@@ -92,6 +92,8 @@ export interface ViewerGraph {
92
92
  mutualIds: string[];
93
93
  /** Accounts the viewer has blocked (bounded). */
94
94
  blockedIds: string[];
95
+ /** Accounts the viewer has restricted (bounded). */
96
+ restrictedIds: string[];
95
97
  }
96
98
 
97
99
  /** Per-user outcome returned by `POST /users/unfollow/bulk`. */
@@ -1044,6 +1046,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
1044
1046
  followingIds: graph?.followingIds || [],
1045
1047
  mutualIds: graph?.mutualIds || [],
1046
1048
  blockedIds: graph?.blockedIds || [],
1049
+ restrictedIds: graph?.restrictedIds || [],
1047
1050
  };
1048
1051
  } catch (error) {
1049
1052
  throw this.handleError(error);
@@ -134,10 +134,12 @@ describe('privacy cache invalidation', () => {
134
134
  fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
135
135
  await oxy.blockUser('u1');
136
136
  expect(clearSpy).toHaveBeenCalledWith('GET:/privacy/blocked');
137
+ expect(clearSpy).toHaveBeenCalledWith('GET:/users/me/graph');
137
138
 
138
139
  fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
139
140
  await oxy.restrictUser('u2');
140
141
  expect(clearSpy).toHaveBeenCalledWith('GET:/privacy/restricted');
142
+ expect(clearSpy).toHaveBeenCalledWith('GET:/users/me/graph');
141
143
 
142
144
  fetchMock.mockResolvedValueOnce(jsonResponse({ isPrivateAccount: true }));
143
145
  await oxy.updatePrivacySettings({ isPrivateAccount: true }, 'me');