@oxy.so/services 13.12.0 → 13.13.0

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.
Files changed (119) hide show
  1. package/README.md +7 -0
  2. package/lib/commonjs/ui/client.js +7 -0
  3. package/lib/commonjs/ui/client.js.map +1 -1
  4. package/lib/commonjs/ui/context/commitSessionFlow.js +4 -0
  5. package/lib/commonjs/ui/context/commitSessionFlow.js.map +1 -1
  6. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +17 -2
  7. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  8. package/lib/commonjs/ui/context/oxyContextHelpers.js +2 -0
  9. package/lib/commonjs/ui/context/oxyContextHelpers.js.map +1 -1
  10. package/lib/commonjs/ui/hooks/useFollowTarget.js +9 -1
  11. package/lib/commonjs/ui/hooks/useFollowTarget.js.map +1 -1
  12. package/lib/commonjs/ui/hooks/useSessionManagement.js +3 -0
  13. package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
  14. package/lib/commonjs/ui/index.js +7 -0
  15. package/lib/commonjs/ui/index.js.map +1 -1
  16. package/lib/commonjs/ui/runtime/createOxyRuntime.js +1 -0
  17. package/lib/commonjs/ui/runtime/createOxyRuntime.js.map +1 -1
  18. package/lib/commonjs/ui/stores/followStore.js +14 -0
  19. package/lib/commonjs/ui/stores/followStore.js.map +1 -1
  20. package/lib/commonjs/ui/stores/followTargetStore.js +15 -5
  21. package/lib/commonjs/ui/stores/followTargetStore.js.map +1 -1
  22. package/lib/commonjs/ui/utils/errorHandlers.js +2 -0
  23. package/lib/commonjs/ui/utils/errorHandlers.js.map +1 -1
  24. package/lib/commonjs/ui/utils/followErrors.js +2 -0
  25. package/lib/commonjs/ui/utils/followErrors.js.map +1 -1
  26. package/lib/commonjs/ui/utils/organizationDraft.js +17 -0
  27. package/lib/commonjs/ui/utils/organizationDraft.js.map +1 -0
  28. package/lib/commonjs/ui/utils/sessionChangedError.js +20 -0
  29. package/lib/commonjs/ui/utils/sessionChangedError.js.map +1 -0
  30. package/lib/commonjs/ui/utils/sessionHelpers.js +2 -0
  31. package/lib/commonjs/ui/utils/sessionHelpers.js.map +1 -1
  32. package/lib/module/ui/client.js +3 -0
  33. package/lib/module/ui/client.js.map +1 -1
  34. package/lib/module/ui/context/commitSessionFlow.js +4 -0
  35. package/lib/module/ui/context/commitSessionFlow.js.map +1 -1
  36. package/lib/module/ui/context/hooks/useAuthOperations.js +17 -2
  37. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  38. package/lib/module/ui/context/oxyContextHelpers.js +3 -0
  39. package/lib/module/ui/context/oxyContextHelpers.js.map +1 -1
  40. package/lib/module/ui/hooks/useFollowTarget.js +10 -2
  41. package/lib/module/ui/hooks/useFollowTarget.js.map +1 -1
  42. package/lib/module/ui/hooks/useSessionManagement.js +3 -0
  43. package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
  44. package/lib/module/ui/index.js +3 -0
  45. package/lib/module/ui/index.js.map +1 -1
  46. package/lib/module/ui/runtime/createOxyRuntime.js +1 -0
  47. package/lib/module/ui/runtime/createOxyRuntime.js.map +1 -1
  48. package/lib/module/ui/stores/followStore.js +14 -0
  49. package/lib/module/ui/stores/followStore.js.map +1 -1
  50. package/lib/module/ui/stores/followTargetStore.js +14 -5
  51. package/lib/module/ui/stores/followTargetStore.js.map +1 -1
  52. package/lib/module/ui/utils/errorHandlers.js +2 -0
  53. package/lib/module/ui/utils/errorHandlers.js.map +1 -1
  54. package/lib/module/ui/utils/followErrors.js +3 -0
  55. package/lib/module/ui/utils/followErrors.js.map +1 -1
  56. package/lib/module/ui/utils/organizationDraft.js +13 -0
  57. package/lib/module/ui/utils/organizationDraft.js.map +1 -0
  58. package/lib/module/ui/utils/sessionChangedError.js +15 -0
  59. package/lib/module/ui/utils/sessionChangedError.js.map +1 -0
  60. package/lib/module/ui/utils/sessionHelpers.js +3 -0
  61. package/lib/module/ui/utils/sessionHelpers.js.map +1 -1
  62. package/lib/typescript/commonjs/ui/client.d.ts +1 -0
  63. package/lib/typescript/commonjs/ui/client.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/context/oxyContextHelpers.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/ui/index.d.ts +1 -0
  70. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/ui/runtime/createOxyRuntime.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts +2 -0
  74. package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/ui/utils/errorHandlers.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/ui/utils/followErrors.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/ui/utils/organizationDraft.d.ts +9 -0
  78. package/lib/typescript/commonjs/ui/utils/organizationDraft.d.ts.map +1 -0
  79. package/lib/typescript/commonjs/ui/utils/sessionChangedError.d.ts +8 -0
  80. package/lib/typescript/commonjs/ui/utils/sessionChangedError.d.ts.map +1 -0
  81. package/lib/typescript/commonjs/ui/utils/sessionHelpers.d.ts.map +1 -1
  82. package/lib/typescript/module/ui/client.d.ts +1 -0
  83. package/lib/typescript/module/ui/client.d.ts.map +1 -1
  84. package/lib/typescript/module/ui/context/commitSessionFlow.d.ts.map +1 -1
  85. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  86. package/lib/typescript/module/ui/context/oxyContextHelpers.d.ts.map +1 -1
  87. package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts.map +1 -1
  88. package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
  89. package/lib/typescript/module/ui/index.d.ts +1 -0
  90. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  91. package/lib/typescript/module/ui/runtime/createOxyRuntime.d.ts.map +1 -1
  92. package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
  93. package/lib/typescript/module/ui/stores/followTargetStore.d.ts +2 -0
  94. package/lib/typescript/module/ui/stores/followTargetStore.d.ts.map +1 -1
  95. package/lib/typescript/module/ui/utils/errorHandlers.d.ts.map +1 -1
  96. package/lib/typescript/module/ui/utils/followErrors.d.ts.map +1 -1
  97. package/lib/typescript/module/ui/utils/organizationDraft.d.ts +9 -0
  98. package/lib/typescript/module/ui/utils/organizationDraft.d.ts.map +1 -0
  99. package/lib/typescript/module/ui/utils/sessionChangedError.d.ts +8 -0
  100. package/lib/typescript/module/ui/utils/sessionChangedError.d.ts.map +1 -0
  101. package/lib/typescript/module/ui/utils/sessionHelpers.d.ts.map +1 -1
  102. package/package.json +14 -2
  103. package/src/ui/client.ts +3 -0
  104. package/src/ui/context/__tests__/commitSessionFlow.test.ts +36 -0
  105. package/src/ui/context/commitSessionFlow.ts +4 -0
  106. package/src/ui/context/hooks/useAuthOperations.ts +14 -2
  107. package/src/ui/context/oxyContextHelpers.ts +3 -0
  108. package/src/ui/hooks/useFollowTarget.ts +10 -1
  109. package/src/ui/hooks/useSessionManagement.ts +3 -0
  110. package/src/ui/index.ts +3 -0
  111. package/src/ui/runtime/createOxyRuntime.ts +1 -0
  112. package/src/ui/stores/__tests__/followStore.test.ts +62 -0
  113. package/src/ui/stores/followStore.ts +14 -0
  114. package/src/ui/stores/followTargetStore.ts +11 -1
  115. package/src/ui/utils/errorHandlers.ts +2 -0
  116. package/src/ui/utils/followErrors.ts +3 -0
  117. package/src/ui/utils/organizationDraft.ts +17 -0
  118. package/src/ui/utils/sessionChangedError.ts +14 -0
  119. package/src/ui/utils/sessionHelpers.ts +2 -0
@@ -1,6 +1,7 @@
1
1
  import type { User } from '@oxy.so/core';
2
2
  import { logger as loggerUtil } from '@oxy.so/core';
3
3
  import { isUnauthorizedStatus } from './oxyContextHelpers';
4
+ import { isSessionChangedError } from '../utils/sessionChangedError';
4
5
 
5
6
  const LOG_CONTEXT = { component: 'OxyContext', method: 'commitSession' } as const;
6
7
 
@@ -79,6 +80,7 @@ export async function commitDeviceSetAndResolve(
79
80
  };
80
81
 
81
82
  const logReconcileError = (registrationError: unknown): void => {
83
+ if (isSessionChangedError(registrationError)) return;
82
84
  // A 401 here is the EXPECTED signed-out edge: the bearer was stale/cleared, so
83
85
  // the device-set registration (`POST /session/device/add`) cannot carry auth.
84
86
  // This is not a failure — the cold boot re-registers on the next load once a
@@ -107,6 +109,7 @@ export async function commitDeviceSetAndResolve(
107
109
  try {
108
110
  fullUser = await getCurrentUser();
109
111
  } catch (profileError) {
112
+ if (isSessionChangedError(profileError)) throw profileError;
110
113
  if (__DEV__) {
111
114
  loggerUtil.debug(
112
115
  'Failed to fetch full user on commit; using minimal fallback',
@@ -137,6 +140,7 @@ export async function commitDeviceSetAndResolve(
137
140
  try {
138
141
  await reconcileDeviceSet();
139
142
  } catch (registrationError) {
143
+ if (isSessionChangedError(registrationError)) throw registrationError;
140
144
  logReconcileError(registrationError);
141
145
  }
142
146
  await hydrateAndResolve();
@@ -1,5 +1,6 @@
1
1
  import { useCallback } from 'react';
2
2
  import { logoutIsolatedOAuthSession, readIsolatedOAuthSession } from '../isolatedOAuthSession';
3
+ import { isSessionChangedError } from '../../utils/sessionChangedError';
3
4
  import type { ApiError, User } from '@oxy.so/core';
4
5
  import type { AuthStateStore, IdentityBinding, SessionClient } from '@oxy.so/core/session';
5
6
  import type { ClientSession, SessionLoginResponse } from '@oxy.so/core';
@@ -174,6 +175,7 @@ export const useAuthOperations = ({
174
175
  });
175
176
  await refreshPinnedAccountId?.();
176
177
  } catch (pinError) {
178
+ if (isSessionChangedError(pinError)) throw pinError;
177
179
  logger?.('Failed to persist identity pin after sign-in', pinError);
178
180
  }
179
181
  }
@@ -192,6 +194,7 @@ export const useAuthOperations = ({
192
194
  await syncFromClient();
193
195
  deviceRegistrationCommitted = true;
194
196
  } catch (registrationError) {
197
+ if (isSessionChangedError(registrationError)) throw registrationError;
195
198
  logger?.('Failed to register sign-in into device session set', registrationError);
196
199
  }
197
200
 
@@ -214,6 +217,7 @@ export const useAuthOperations = ({
214
217
  },
215
218
  );
216
219
  } catch (error) {
220
+ if (isSessionChangedError(error)) throw error;
217
221
  if (__DEV__ && logger) {
218
222
  logger('Failed to fetch device sessions after login', error);
219
223
  }
@@ -231,6 +235,7 @@ export const useAuthOperations = ({
231
235
  try {
232
236
  await oxyServices.session.logout(sessionResponse.sessionId, sessionResponse.sessionId);
233
237
  } catch (logoutError) {
238
+ if (isSessionChangedError(logoutError)) throw logoutError;
234
239
  // Non-critical - continue to switch session even if logout fails
235
240
  if (__DEV__ && logger) {
236
241
  logger('Failed to logout duplicate session, continuing with switch', logoutError);
@@ -289,6 +294,7 @@ export const useAuthOperations = ({
289
294
  */
290
295
  const signIn = useCallback(
291
296
  async (publicKey: string, deviceName?: string): Promise<User> => {
297
+ let superseded = false;
292
298
  runtime.batch(() => {
293
299
  runtime.setLoading(true);
294
300
  runtime.setError(null);
@@ -297,6 +303,10 @@ export const useAuthOperations = ({
297
303
  try {
298
304
  return await performSignIn(publicKey);
299
305
  } catch (error) {
306
+ if (isSessionChangedError(error)) {
307
+ superseded = true;
308
+ throw error;
309
+ }
300
310
  const message = handleAuthError(error, {
301
311
  defaultMessage: 'Sign in failed',
302
312
  code: LOGIN_ERROR_CODE,
@@ -307,7 +317,7 @@ export const useAuthOperations = ({
307
317
  runtime.setError(message);
308
318
  throw error;
309
319
  } finally {
310
- runtime.setLoading(false);
320
+ if (!superseded) runtime.setLoading(false);
311
321
  }
312
322
  },
313
323
  [runtime, performSignIn, onError, logger],
@@ -329,7 +339,7 @@ export const useAuthOperations = ({
329
339
  revokeSelf: (sessionId) => oxyServices.session.logout(sessionId),
330
340
  clearSessionState,
331
341
  });
332
- if (result.status === 'failed') {
342
+ if (result.status === 'failed' && !isSessionChangedError(result.error)) {
333
343
  handleAuthError(result.error, {
334
344
  defaultMessage: 'Logout failed',
335
345
  code: LOGOUT_ERROR_CODE,
@@ -378,6 +388,7 @@ export const useAuthOperations = ({
378
388
  }
379
389
  return { status: 'signed-out' };
380
390
  } catch (error) {
391
+ if (isSessionChangedError(error)) return { status: 'failed', error };
381
392
  const isInvalid = isInvalidSessionError(error);
382
393
 
383
394
  // Compare the RESOLVED target, not the raw optional parameter: every
@@ -459,6 +470,7 @@ export const useAuthOperations = ({
459
470
  await clearPersistedAuthSafe(store, logger);
460
471
  await clearSessionState();
461
472
  } catch (error) {
473
+ if (isSessionChangedError(error)) throw error;
462
474
  if (isInvalidSessionError(error)) {
463
475
  // An already-invalid bearer means the sessions this call would have
464
476
  // revoked are ALREADY gone — the sign-out happened, it just happened
@@ -1,3 +1,5 @@
1
+ import { isSessionChangedError } from '../utils/sessionChangedError';
2
+
1
3
  /** Local display hint when a session lacks explicit `expiresAt` (7 days). */
2
4
  export const DEFAULT_SESSION_VALIDITY_MS = 7 * 24 * 60 * 60 * 1000;
3
5
 
@@ -24,5 +26,6 @@ export function getHttpStatus(error: unknown): number | undefined {
24
26
  }
25
27
 
26
28
  export function isUnauthorizedStatus(error: unknown): boolean {
29
+ if (isSessionChangedError(error)) return false;
27
30
  return getHttpStatus(error) === 401;
28
31
  }
@@ -20,6 +20,7 @@ import type { FollowStatus } from '@oxy.so/contracts';
20
20
  import { useOxy } from '../context/oxyRuntimeContext';
21
21
  import {
22
22
  isCompleteFollowStatus,
23
+ getFollowTargetGeneration,
23
24
  isFollowedGlobally,
24
25
  UNKNOWN_FOLLOW_STATUS,
25
26
  useFollowTargetStore,
@@ -92,11 +93,14 @@ export function useFollowTarget(
92
93
  // that window 401s and would leave the button stuck reporting an error the
93
94
  // user cannot act on.
94
95
  if (!targetId || !canUsePrivateApi) return;
96
+ const generation = getFollowTargetGeneration();
95
97
  try {
96
98
  const next = await oxyServices.follows.targetStatus(targetId);
99
+ if (generation !== getFollowTargetGeneration()) return;
97
100
  useFollowTargetStore.getState().setStatus(targetId, next);
98
101
  useFollowTargetStore.getState().setError(targetId, undefined);
99
102
  } catch (e) {
103
+ if (generation !== getFollowTargetGeneration()) return;
100
104
  const store = useFollowTargetStore.getState();
101
105
  // Leave `isUnknown` false so the button stays interactive and can retry.
102
106
  // A failed read is not the same as "never asked" — disabling forever would
@@ -127,6 +131,7 @@ export function useFollowTarget(
127
131
  failure: FollowActionErrorCode,
128
132
  ): Promise<boolean> => {
129
133
  if (!targetId) return false;
134
+ const generation = getFollowTargetGeneration();
130
135
  const store = useFollowTargetStore.getState();
131
136
  const previous = store.statuses[targetId];
132
137
  store.setStatus(targetId, optimistic);
@@ -134,9 +139,11 @@ export function useFollowTarget(
134
139
  store.setError(targetId, undefined);
135
140
  try {
136
141
  const settled = await run();
142
+ if (generation !== getFollowTargetGeneration()) return false;
137
143
  if (settled) useFollowTargetStore.getState().setStatus(targetId, settled);
138
144
  return true;
139
145
  } catch (e) {
146
+ if (generation !== getFollowTargetGeneration()) return false;
140
147
  const s = useFollowTargetStore.getState();
141
148
  // Back to what was true, not to a guess. Clearing the entry instead
142
149
  // would make the next render ask again and flash the wrong state.
@@ -144,7 +151,9 @@ export function useFollowTarget(
144
151
  s.setError(targetId, toFollowErrorCode(e, failure));
145
152
  return false;
146
153
  } finally {
147
- useFollowTargetStore.getState().setPending(targetId, false);
154
+ if (generation === getFollowTargetGeneration()) {
155
+ useFollowTargetStore.getState().setPending(targetId, false);
156
+ }
148
157
  }
149
158
  },
150
159
  [targetId],
@@ -6,6 +6,7 @@ import { getStorageKeys, type StorageInterface } from '../utils/storageHelpers';
6
6
  import { handleAuthError, isInvalidSessionError } from '../utils/errorHandlers';
7
7
  import type { OxyServices } from '@oxy.so/core';
8
8
  import type { QueryClient } from '@tanstack/react-query';
9
+ import { isSessionChangedError } from '../utils/sessionChangedError';
9
10
  import { clearQueryCache } from './queryClient';
10
11
  import { isWebBrowser } from '../utils/isWebBrowser';
11
12
  import { resetSessionScopedStores } from '../stores/resetSessionScopedStores';
@@ -188,6 +189,7 @@ export const useSessionManagement = ({
188
189
  });
189
190
  activeRuntime.mergeSessions(deviceSessions, { merge: true });
190
191
  } catch (error) {
192
+ if (isSessionChangedError(error)) throw error;
191
193
  if (isDev()) {
192
194
  console.warn('Failed to synchronize sessions after switch:', error);
193
195
  }
@@ -195,6 +197,7 @@ export const useSessionManagement = ({
195
197
 
196
198
  return user;
197
199
  } catch (error) {
200
+ if (isSessionChangedError(error)) throw error;
198
201
  const invalidSession = isInvalidSessionError(error);
199
202
 
200
203
  if (invalidSession) {
package/src/ui/index.ts CHANGED
@@ -24,6 +24,9 @@ import '../registerPlatform';
24
24
  // in the first-paint bundle of every app that imports anything from `ui`
25
25
  // (Allo: +340 KB gzipped).
26
26
 
27
+ // Shared organization form validation does not grant creation authority.
28
+ export { parseOrganizationDraft } from './utils/organizationDraft';
29
+
27
30
  // Components
28
31
  export { default as OxyProvider } from './components/OxyProvider';
29
32
  export { default as OxySignInButton } from './components/OxySignInButton';
@@ -652,6 +652,7 @@ export function createOxyRuntime(config: OxyRuntimeConfig): OxyRuntime {
652
652
  facts.directory = null;
653
653
  facts.profiles = new Map();
654
654
  facts.pinnedAccountId = null;
655
+ facts.isLoading = false;
655
656
  touch();
656
657
  });
657
658
  },
@@ -190,3 +190,65 @@ describe('resetFollowState', () => {
190
190
  expect(useFollowStore.getState().followingUsers).toEqual({});
191
191
  });
192
192
  });
193
+
194
+ describe('follow operations across account resets', () => {
195
+ it.each(['resolve', 'reject'] as const)(
196
+ 'does not reconcile a previous account toggle when it %s',
197
+ async (outcome) => {
198
+ const { mock, services } = makeServices();
199
+ let resolve!: (result: FollowMutationResult) => void;
200
+ let reject!: (error: Error) => void;
201
+ mock.follows.follow.mockReturnValueOnce(
202
+ new Promise((res, rej) => {
203
+ resolve = res;
204
+ reject = rej;
205
+ }),
206
+ );
207
+ const pending = useFollowStore.getState().toggleFollowUser('u1', services, false);
208
+ useFollowStore.getState().resetFollowState();
209
+ useFollowStore.setState({
210
+ followingUsers: { u1: true },
211
+ loadingUsers: { u1: true },
212
+ errors: { u1: 'read_failed' },
213
+ });
214
+ if (outcome === 'resolve')
215
+ resolve({ message: 'ok', counts: { followers: 10, following: 3 } });
216
+ else reject(new Error('old request failed'));
217
+ expect(await pending).toBe(false);
218
+ const state = useFollowStore.getState();
219
+ expect(state.followingUsers.u1).toBe(true);
220
+ expect(state.loadingUsers.u1).toBe(true);
221
+ expect(state.errors.u1).toBe('read_failed');
222
+ expect(state.followerCounts.u1).toBeUndefined();
223
+ },
224
+ );
225
+
226
+ it.each(['followMany', 'unfollowMany'] as const)(
227
+ 'does not reconcile a previous account %s',
228
+ async (method) => {
229
+ const { services } = makeServices();
230
+ let resolve!: (result: unknown) => void;
231
+ const response = new Promise((res) => {
232
+ resolve = res;
233
+ });
234
+ Object.assign(services.follows, { [method]: jest.fn(() => response) });
235
+ const store = useFollowStore.getState();
236
+ const pending =
237
+ method === 'followMany'
238
+ ? store.followManyUsers(['u1'], services)
239
+ : store.unfollowManyUsers(['u1'], services);
240
+ store.resetFollowState();
241
+ useFollowStore.setState({
242
+ followingUsers: { u1: true },
243
+ loadingUsers: { u1: true },
244
+ errors: { u1: 'read_failed' },
245
+ });
246
+ resolve({ results: [] });
247
+ await expect(pending).rejects.toMatchObject({ code: 'AUTH_SESSION_CHANGED' });
248
+ const state = useFollowStore.getState();
249
+ expect(state.followingUsers.u1).toBe(true);
250
+ expect(state.loadingUsers.u1).toBe(true);
251
+ expect(state.errors.u1).toBe('read_failed');
252
+ },
253
+ );
254
+ });
@@ -6,6 +6,7 @@ import type {
6
6
  FollowMutationResult,
7
7
  } from '@oxy.so/core';
8
8
  import { type FollowErrorCode, toFollowErrorCode } from '../utils/followErrors';
9
+ import { createSessionChangedError } from '../utils/sessionChangedError';
9
10
 
10
11
  interface FollowState {
11
12
  // Tri-state follow map: a MISSING key means UNKNOWN (status not yet resolved),
@@ -206,6 +207,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
206
207
  });
207
208
  })
208
209
  .finally(() => {
210
+ if (generation !== batchGeneration) return;
209
211
  for (const id of ids) inFlightIds.delete(id);
210
212
  });
211
213
  });
@@ -215,6 +217,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
215
217
  oxyServices: OxyServices,
216
218
  isCurrentlyFollowing: boolean,
217
219
  ) => {
220
+ const generation = batchGeneration;
218
221
  // Snapshot the prior value for rollback. `undefined` = was UNKNOWN.
219
222
  const priorState = get().followingUsers;
220
223
  const hadPrevious = hasKey(priorState, userId);
@@ -233,6 +236,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
233
236
  const response: FollowMutationResult = isCurrentlyFollowing
234
237
  ? await oxyServices.follows.unfollow(userId)
235
238
  : await oxyServices.follows.follow(userId);
239
+ if (generation !== batchGeneration) return false;
236
240
 
237
241
  // Reconcile: confirm the optimistic value is authoritative, clear loading.
238
242
  set((state) => ({
@@ -262,6 +266,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
262
266
  }
263
267
  return true;
264
268
  } catch (error: unknown) {
269
+ if (generation !== batchGeneration) return false;
265
270
  // Roll back to the prior value (or back to UNKNOWN if there was none).
266
271
  set((state) => {
267
272
  const followingUsers = { ...state.followingUsers };
@@ -286,6 +291,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
286
291
  userIds: string[],
287
292
  oxyServices: OxyServices,
288
293
  ): Promise<BulkFollowResult> => {
294
+ const generation = batchGeneration;
289
295
  // Snapshot prior values for rollback (tri-state: undefined = unknown).
290
296
  const prior = get().followingUsers;
291
297
  const previous = new Map<string, boolean | undefined>();
@@ -308,6 +314,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
308
314
 
309
315
  try {
310
316
  const result = await oxyServices.follows.followMany(userIds);
317
+ if (generation !== batchGeneration) throw createSessionChangedError();
311
318
  set((state) => {
312
319
  const followingUsers = { ...state.followingUsers };
313
320
  const loadingUsers = { ...state.loadingUsers };
@@ -334,6 +341,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
334
341
  });
335
342
  return result;
336
343
  } catch (error: unknown) {
344
+ if (generation !== batchGeneration) throw createSessionChangedError();
337
345
  const code = toFollowErrorCode(error, 'update_failed');
338
346
  // Whole batch failed — roll ALL targets back to their prior values.
339
347
  set((state) => {
@@ -359,6 +367,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
359
367
  userIds: string[],
360
368
  oxyServices: OxyServices,
361
369
  ): Promise<BulkUnfollowResult> => {
370
+ const generation = batchGeneration;
362
371
  // Snapshot prior values for rollback (tri-state: undefined = unknown).
363
372
  const prior = get().followingUsers;
364
373
  const previous = new Map<string, boolean | undefined>();
@@ -381,6 +390,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
381
390
 
382
391
  try {
383
392
  const result = await oxyServices.follows.unfollowMany(userIds);
393
+ if (generation !== batchGeneration) throw createSessionChangedError();
384
394
  set((state) => {
385
395
  const followingUsers = { ...state.followingUsers };
386
396
  const loadingUsers = { ...state.loadingUsers };
@@ -407,6 +417,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
407
417
  });
408
418
  return result;
409
419
  } catch (error: unknown) {
420
+ if (generation !== batchGeneration) throw createSessionChangedError();
410
421
  const code = toFollowErrorCode(error, 'update_failed');
411
422
  // Whole batch failed — roll ALL targets back to their prior values.
412
423
  set((state) => {
@@ -454,11 +465,13 @@ export const useFollowStore = create<FollowState>((set, get) => ({
454
465
  });
455
466
  },
456
467
  fetchUserCounts: async (userId: string, oxyServices: OxyServices) => {
468
+ const generation = batchGeneration;
457
469
  set((state) => ({
458
470
  loadingCounts: { ...state.loadingCounts, [userId]: true },
459
471
  }));
460
472
  try {
461
473
  const user = await oxyServices.users.get(userId);
474
+ if (generation !== batchGeneration) return;
462
475
  if (user?._count) {
463
476
  set((state) => ({
464
477
  followerCounts: {
@@ -477,6 +490,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
477
490
  }));
478
491
  }
479
492
  } catch (error: unknown) {
493
+ if (generation !== batchGeneration) return;
480
494
  set((state) => ({
481
495
  loadingCounts: { ...state.loadingCounts, [userId]: false },
482
496
  }));
@@ -29,6 +29,13 @@ import type { FollowErrorCode } from '../utils/followErrors';
29
29
  /** The status of one target, or `undefined` when it has never been read. */
30
30
  type StatusMap = Record<string, FollowStatus | undefined>;
31
31
 
32
+ let followTargetGeneration = 0;
33
+
34
+ /** The generation changes whenever account-scoped follow state is reset. */
35
+ export function getFollowTargetGeneration(): number {
36
+ return followTargetGeneration;
37
+ }
38
+
32
39
  interface FollowTargetState {
33
40
  statuses: StatusMap;
34
41
  /** In-flight writes, per target. Reads do not set this — only mutations do. */
@@ -80,7 +87,10 @@ export const useFollowTargetStore = create<FollowTargetState>((set) => ({
80
87
  // account switch the whole map is wrong, not stale — the next user's follows
81
88
  // are a different set entirely, and showing one user their previous
82
89
  // account's follow state is a privacy failure rather than a rendering one.
83
- reset: () => set({ statuses: {}, pending: {}, errors: {} }),
90
+ reset: () => {
91
+ followTargetGeneration += 1;
92
+ set({ statuses: {}, pending: {}, errors: {} });
93
+ },
84
94
  }));
85
95
 
86
96
  /**
@@ -1,4 +1,5 @@
1
1
  import type { ApiError } from '@oxy.so/core';
2
+ import { isSessionChangedError } from './sessionChangedError';
2
3
 
3
4
  type ErrorWithMessage = {
4
5
  message?: string;
@@ -52,6 +53,7 @@ const getResponseStatus = (error: unknown): number | undefined => {
52
53
  * This centralizes 401 detection across different fetch clients.
53
54
  */
54
55
  export const isInvalidSessionError = (error: unknown): boolean => {
56
+ if (isSessionChangedError(error)) return false;
55
57
  const status = getResponseStatus(error);
56
58
  if (status === 401) {
57
59
  return true;
@@ -1,3 +1,5 @@
1
+ import { isSessionChangedError } from './sessionChangedError';
2
+
1
3
  /**
2
4
  * What a failed follow read or write leaves behind: a stable CODE, never text.
3
5
  *
@@ -43,6 +45,7 @@ const OFFLINE_CODES = new Set(['NETWORK_ERROR', 'CONNECTION_FAILED', 'TIMEOUT'])
43
45
  * Anything the cause does not explain falls back to the caller's `action`.
44
46
  */
45
47
  export function toFollowErrorCode(error: unknown, action: FollowActionErrorCode): FollowErrorCode {
48
+ if (isSessionChangedError(error)) return action;
46
49
  const { status, code } = (error && typeof error === 'object' ? error : {}) as {
47
50
  status?: unknown;
48
51
  code?: unknown;
@@ -0,0 +1,17 @@
1
+ import { checkContract, createOrganizationCheck } from '@oxy.so/contracts/client';
2
+ import type { CreateOrganization } from '@oxy.so/contracts';
3
+
4
+ /** Normalize and validate form fields; callers must separately authorize creation. */
5
+ export function parseOrganizationDraft(input: {
6
+ name: string;
7
+ organizationType: CreateOrganization['organizationType'];
8
+ websiteUrl: string;
9
+ countryCode: string;
10
+ }): CreateOrganization | null {
11
+ return checkContract(createOrganizationCheck, {
12
+ name: input.name.trim(),
13
+ organizationType: input.organizationType,
14
+ websiteUrl: input.websiteUrl.trim() || null,
15
+ countryCode: input.countryCode.trim().toUpperCase() || null,
16
+ });
17
+ }
@@ -0,0 +1,14 @@
1
+ /** A locally superseded request is not a server rejection of the new session. */
2
+ export function isSessionChangedError(error: unknown): boolean {
3
+ return (
4
+ !!error && typeof error === 'object' && 'code' in error && error.code === 'AUTH_SESSION_CHANGED'
5
+ );
6
+ }
7
+
8
+ /** A locally superseded operation must not be mistaken for a server 401. */
9
+ export function createSessionChangedError(): Error & { code: string; status: number } {
10
+ return Object.assign(new Error('The request session changed'), {
11
+ code: 'AUTH_SESSION_CHANGED',
12
+ status: 401,
13
+ });
14
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ClientSession } from '@oxy.so/core';
2
+ import { isSessionChangedError } from './sessionChangedError';
2
3
 
3
4
  interface DeviceSession {
4
5
  sessionId: string;
@@ -73,6 +74,7 @@ export const fetchSessionsWithFallback = async (
73
74
  const deviceSessions = await oxyServices.devices.sessions(sessionId);
74
75
  return mapSessionsToClient(deviceSessions, fallbackDeviceId, fallbackUserId);
75
76
  } catch (error) {
77
+ if (isSessionChangedError(error)) throw error;
76
78
  if (__DEV__ && logger) {
77
79
  logger('Failed to get device sessions, falling back to user sessions', error);
78
80
  }