@oxyhq/services 19.1.1 → 19.1.3

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 (110) hide show
  1. package/lib/commonjs/index.js +7 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/boot/runProviderColdBoot.js +4 -0
  4. package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
  5. package/lib/commonjs/ui/components/OxyOAuthCallback.js +3 -4
  6. package/lib/commonjs/ui/components/OxyOAuthCallback.js.map +1 -1
  7. package/lib/commonjs/ui/context/OxyContext.js +53 -52
  8. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  9. package/lib/commonjs/ui/context/commitSessionFlow.js +91 -0
  10. package/lib/commonjs/ui/context/commitSessionFlow.js.map +1 -0
  11. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +1 -2
  12. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  13. package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
  14. package/lib/commonjs/ui/hooks/useOxyEvent.js +28 -0
  15. package/lib/commonjs/ui/hooks/useOxyEvent.js.map +1 -0
  16. package/lib/commonjs/ui/screens/CreateAccountScreen.js +53 -2
  17. package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -1
  18. package/lib/commonjs/ui/screens/FileManagementScreen.js +3 -9
  19. package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
  20. package/lib/commonjs/ui/session/createSessionClient.js +12 -8
  21. package/lib/commonjs/ui/session/createSessionClient.js.map +1 -1
  22. package/lib/commonjs/ui/session/tokenTransport.js +22 -32
  23. package/lib/commonjs/ui/session/tokenTransport.js.map +1 -1
  24. package/lib/commonjs/ui/utils/oauthReturn.js +27 -0
  25. package/lib/commonjs/ui/utils/oauthReturn.js.map +1 -1
  26. package/lib/module/index.js +1 -0
  27. package/lib/module/index.js.map +1 -1
  28. package/lib/module/ui/boot/runProviderColdBoot.js +5 -1
  29. package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
  30. package/lib/module/ui/components/OxyOAuthCallback.js +3 -4
  31. package/lib/module/ui/components/OxyOAuthCallback.js.map +1 -1
  32. package/lib/module/ui/context/OxyContext.js +54 -53
  33. package/lib/module/ui/context/OxyContext.js.map +1 -1
  34. package/lib/module/ui/context/commitSessionFlow.js +87 -0
  35. package/lib/module/ui/context/commitSessionFlow.js.map +1 -0
  36. package/lib/module/ui/context/hooks/useAuthOperations.js +1 -2
  37. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  38. package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
  39. package/lib/module/ui/hooks/useOxyEvent.js +25 -0
  40. package/lib/module/ui/hooks/useOxyEvent.js.map +1 -0
  41. package/lib/module/ui/screens/CreateAccountScreen.js +53 -2
  42. package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -1
  43. package/lib/module/ui/screens/FileManagementScreen.js +3 -9
  44. package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
  45. package/lib/module/ui/session/createSessionClient.js +12 -8
  46. package/lib/module/ui/session/createSessionClient.js.map +1 -1
  47. package/lib/module/ui/session/tokenTransport.js +23 -33
  48. package/lib/module/ui/session/tokenTransport.js.map +1 -1
  49. package/lib/module/ui/utils/oauthReturn.js +26 -0
  50. package/lib/module/ui/utils/oauthReturn.js.map +1 -1
  51. package/lib/typescript/commonjs/index.d.ts +1 -0
  52. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts +39 -0
  56. package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +3 -1
  59. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +3 -2
  61. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/ui/hooks/useOxyEvent.d.ts +8 -0
  63. package/lib/typescript/commonjs/ui/hooks/useOxyEvent.d.ts.map +1 -0
  64. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +12 -8
  67. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +11 -9
  69. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/ui/utils/oauthReturn.d.ts +6 -0
  71. package/lib/typescript/commonjs/ui/utils/oauthReturn.d.ts.map +1 -1
  72. package/lib/typescript/module/index.d.ts +1 -0
  73. package/lib/typescript/module/index.d.ts.map +1 -1
  74. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  75. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  76. package/lib/typescript/module/ui/context/commitSessionFlow.d.ts +39 -0
  77. package/lib/typescript/module/ui/context/commitSessionFlow.d.ts.map +1 -0
  78. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  79. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +3 -1
  80. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +3 -2
  82. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  83. package/lib/typescript/module/ui/hooks/useOxyEvent.d.ts +8 -0
  84. package/lib/typescript/module/ui/hooks/useOxyEvent.d.ts.map +1 -0
  85. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  86. package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
  87. package/lib/typescript/module/ui/session/createSessionClient.d.ts +12 -8
  88. package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -1
  89. package/lib/typescript/module/ui/session/tokenTransport.d.ts +11 -9
  90. package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -1
  91. package/lib/typescript/module/ui/utils/oauthReturn.d.ts +6 -0
  92. package/lib/typescript/module/ui/utils/oauthReturn.d.ts.map +1 -1
  93. package/package.json +16 -16
  94. package/src/index.ts +1 -0
  95. package/src/ui/boot/runProviderColdBoot.ts +5 -1
  96. package/src/ui/components/OxyOAuthCallback.tsx +3 -3
  97. package/src/ui/context/OxyContext.tsx +52 -52
  98. package/src/ui/context/__tests__/commitSessionFlow.test.ts +192 -0
  99. package/src/ui/context/commitSessionFlow.ts +115 -0
  100. package/src/ui/context/hooks/useAuthOperations.ts +2 -3
  101. package/src/ui/context/oxyContextTypes.ts +3 -1
  102. package/src/ui/context/useOxyAccountGraph.ts +1 -1
  103. package/src/ui/hooks/useOxyEvent.ts +22 -0
  104. package/src/ui/screens/CreateAccountScreen.tsx +59 -2
  105. package/src/ui/screens/FileManagementScreen.tsx +3 -9
  106. package/src/ui/session/__tests__/createSessionClient.test.ts +5 -5
  107. package/src/ui/session/__tests__/tokenTransport.test.ts +43 -60
  108. package/src/ui/session/createSessionClient.ts +13 -10
  109. package/src/ui/session/tokenTransport.ts +22 -41
  110. package/src/ui/utils/oauthReturn.ts +29 -0
@@ -83,7 +83,7 @@ export function useOxyAccountGraph({
83
83
  {
84
84
  sessionId: result.sessionId,
85
85
  accessToken: result.accessToken,
86
- deviceSecret: (result as { deviceSecret?: string }).deviceSecret,
86
+ deviceSecret: result.deviceSecret,
87
87
  deviceId: result.deviceId,
88
88
  expiresAt: result.expiresAt,
89
89
  userId: result.user.id,
@@ -0,0 +1,22 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ import { useOxy } from '../context/OxyContext';
4
+
5
+ /**
6
+ * Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`)
7
+ * for the lifetime of the component. Payloads arrive as `unknown` — callers
8
+ * validate shape. Handler identity may change between renders; the latest one
9
+ * is always invoked.
10
+ */
11
+ export function useOxyEvent(event: string, handler: (payload: unknown) => void): void {
12
+ const { sessionClient } = useOxy();
13
+ const handlerRef = useRef(handler);
14
+ handlerRef.current = handler;
15
+
16
+ useEffect(() => {
17
+ if (!sessionClient) return;
18
+ return sessionClient.onServerEvent(event, (payload) => {
19
+ handlerRef.current(payload);
20
+ });
21
+ }, [sessionClient, event]);
22
+ }
@@ -9,7 +9,8 @@ import {
9
9
  TouchableOpacity,
10
10
  } from 'react-native';
11
11
  import { Ionicons } from '@expo/vector-icons';
12
- import type { AccountKind, CreateAccountInput } from '@oxyhq/core';
12
+ import type { AccountKind, CreateAccountInput, OrganizationCategory } from '@oxyhq/core';
13
+ import { ORGANIZATION_CATEGORIES } from '@oxyhq/core';
13
14
  import type { BaseScreenProps } from '../types/navigation';
14
15
  import Header from '../components/Header';
15
16
  import { useI18n } from '../hooks/useI18n';
@@ -72,6 +73,24 @@ const kindDescription = (
72
73
  }
73
74
  };
74
75
 
76
+ const organizationCategoryLabel = (
77
+ t: (key: string, vars?: Record<string, string | number>) => string,
78
+ category: OrganizationCategory,
79
+ ): string => {
80
+ switch (category) {
81
+ case 'agency':
82
+ return t('accounts.organizationCategory.agency') || 'Real estate agency';
83
+ case 'cooperative':
84
+ return t('accounts.organizationCategory.cooperative') || 'Housing cooperative';
85
+ case 'landlord':
86
+ return t('accounts.organizationCategory.landlord') || 'Landlord / property manager';
87
+ default:
88
+ return t('accounts.organizationCategory.other') || 'Other organization';
89
+ }
90
+ };
91
+
92
+ const ORGANIZATION_CATEGORY_OPTIONS: OrganizationCategory[] = [...ORGANIZATION_CATEGORIES];
93
+
75
94
  /**
76
95
  * Create a new account in the unified account graph (an organization, project,
77
96
  * or bot). The caller becomes its owner. Optionally nested under a parent
@@ -90,6 +109,7 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
90
109
  const parentId = typeof parentAccountId === 'string' ? parentAccountId : undefined;
91
110
 
92
111
  const [kind, setKind] = useState<CreatableAccountKind>('project');
112
+ const [organizationCategory, setOrganizationCategory] = useState<OrganizationCategory>('agency');
93
113
  const [username, setUsername] = useState('');
94
114
  const [displayName, setDisplayName] = useState('');
95
115
  const [bio, setBio] = useState('');
@@ -175,6 +195,7 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
175
195
  username,
176
196
  name: { first: firstName, last: lastName },
177
197
  bio: bio.trim() || undefined,
198
+ ...(kind === 'organization' ? { organizationCategory } : null),
178
199
  ...(parentId ? { parentAccountId: parentId } : null),
179
200
  };
180
201
  const account = await createAccount(input);
@@ -197,7 +218,7 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
197
218
  } finally {
198
219
  setIsCreating(false);
199
220
  }
200
- }, [canCreate, kind, username, displayName, bio, parentId, createAccount, switchToAccount, onClose, t]);
221
+ }, [canCreate, kind, organizationCategory, username, displayName, bio, parentId, createAccount, switchToAccount, onClose, t]);
201
222
 
202
223
  // Status icon + color shown alongside the username field message
203
224
  const usernameIsInvalid = usernameStatus === 'taken' || usernameStatus === 'invalid';
@@ -285,6 +306,42 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
285
306
  })}
286
307
  </View>
287
308
 
309
+ {kind === 'organization' ? (
310
+ <View className="gap-space-8">
311
+ <Text className="text-body font-bodyBold text-text">
312
+ {t('accounts.create.organizationCategory.label') || 'Organization type'}
313
+ </Text>
314
+ <View className="flex-row flex-wrap gap-space-8">
315
+ {ORGANIZATION_CATEGORY_OPTIONS.map((option) => {
316
+ const selected = option === organizationCategory;
317
+ return (
318
+ <TouchableOpacity
319
+ key={option}
320
+ accessibilityRole="radio"
321
+ accessibilityState={{ selected }}
322
+ accessibilityLabel={organizationCategoryLabel(t, option)}
323
+ onPress={() => setOrganizationCategory(option)}
324
+ activeOpacity={0.7}
325
+ className="px-space-12 py-space-8 rounded-radius-full"
326
+ style={{
327
+ backgroundColor: selected ? bloomTheme.colors.primarySubtle : bloomTheme.colors.card,
328
+ borderWidth: 1,
329
+ borderColor: selected ? bloomTheme.colors.primary : bloomTheme.colors.border,
330
+ }}
331
+ >
332
+ <Text
333
+ className="text-caption font-captionBold"
334
+ style={{ color: selected ? bloomTheme.colors.primary : bloomTheme.colors.text }}
335
+ >
336
+ {organizationCategoryLabel(t, option)}
337
+ </Text>
338
+ </TouchableOpacity>
339
+ );
340
+ })}
341
+ </View>
342
+ </View>
343
+ ) : null}
344
+
288
345
  {/* Form Content */}
289
346
  <View className="gap-space-16 p-space-16 rounded-radius-20 bg-fill">
290
347
  {/* Username */}
@@ -242,7 +242,6 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
242
242
  const [lastSelectedFileId, setLastSelectedFileId] = useState<string | null>(null);
243
243
  const scrollViewRef = useRef<ScrollView>(null);
244
244
  const photoScrollViewRef = useRef<ScrollView>(null);
245
- const itemRefs = useRef<Map<string, number>>(new Map()); // Track item positions
246
245
  const containerRef = useRef<View>(null);
247
246
  useEffect(() => {
248
247
  if (initialSelectedIds?.length) {
@@ -1003,11 +1002,6 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
1003
1002
  // filteredFiles is already sorted, so just use it directly
1004
1003
  const sortedFiles = filteredFiles;
1005
1004
 
1006
- // Store file positions for scrolling
1007
- sortedFiles.forEach((file, index) => {
1008
- itemRefs.current.set(file.id, index);
1009
- });
1010
-
1011
1005
  return sortedFiles.map((file) => {
1012
1006
  const isImage = file.contentType.startsWith('image/');
1013
1007
  const isPDF = file.contentType.includes('pdf');
@@ -1114,10 +1108,10 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
1114
1108
  useEffect(() => {
1115
1109
  if (lastSelectedFileId && selectMode) {
1116
1110
  if (viewMode === 'all' && scrollViewRef.current) {
1117
- // Find the index of the selected file
1118
- const itemIndex = itemRefs.current.get(lastSelectedFileId);
1111
+ // Find the index of the selected file (filteredFiles is already sorted)
1112
+ const itemIndex = filteredFiles.findIndex(file => file.id === lastSelectedFileId);
1119
1113
 
1120
- if (itemIndex !== undefined && itemIndex >= 0) {
1114
+ if (itemIndex >= 0) {
1121
1115
  // Estimate item height (GroupedItem with dense mode is approximately 60-70px)
1122
1116
  // Account for description rows which add extra height
1123
1117
  const baseItemHeight = 65;
@@ -1,4 +1,4 @@
1
- import { SessionClient, createMemoryAuthStateStore } from '@oxyhq/core';
1
+ import { SessionClient } from '@oxyhq/core';
2
2
 
3
3
  type Handler = (...args: unknown[]) => void;
4
4
  class FakeSocket {
@@ -36,7 +36,7 @@ describe('createSessionClient', () => {
36
36
  test('wires a SessionClient instance backed by the host + device-first token transport', () => {
37
37
  const oxy = fakeOxy();
38
38
 
39
- const { client, host } = createSessionClient(oxy as never, createMemoryAuthStateStore());
39
+ const { client, host } = createSessionClient(oxy as never);
40
40
 
41
41
  expect(client).toBeInstanceOf(SessionClient);
42
42
  expect(typeof client.bootstrap).toBe('function');
@@ -47,7 +47,7 @@ describe('createSessionClient', () => {
47
47
  test('the returned host reflects setCurrentAccountId', () => {
48
48
  const oxy = fakeOxy();
49
49
 
50
- const { host } = createSessionClient(oxy as never, createMemoryAuthStateStore());
50
+ const { host } = createSessionClient(oxy as never);
51
51
 
52
52
  expect(host.getCurrentAccountId()).toBeNull();
53
53
  host.setCurrentAccountId('u1');
@@ -58,7 +58,7 @@ describe('createSessionClient', () => {
58
58
  ioMock.mockClear();
59
59
  const oxy = fakeOxy();
60
60
 
61
- const { client } = createSessionClient(oxy as never, createMemoryAuthStateStore());
61
+ const { client } = createSessionClient(oxy as never);
62
62
  await client.start();
63
63
 
64
64
  // The injected `io` was used to open the session socket at the base URL.
@@ -72,7 +72,7 @@ describe('createSessionClient', () => {
72
72
  const oxy = fakeOxy();
73
73
  const onUnauthenticated = jest.fn();
74
74
 
75
- const { client } = createSessionClient(oxy as never, createMemoryAuthStateStore(), onUnauthenticated);
75
+ const { client } = createSessionClient(oxy as never, onUnauthenticated);
76
76
 
77
77
  // The client was constructed with the callback (no direct getter is exposed;
78
78
  // constructing without throwing + wiring the socket factory is the contract
@@ -1,23 +1,17 @@
1
1
  import type { DeviceSessionState } from '@oxyhq/contracts';
2
- import { logger, createMemoryAuthStateStore, refreshPersistedSession } from '@oxyhq/core';
2
+ import { logger } from '@oxyhq/core';
3
3
  import { createTokenTransport } from '../tokenTransport';
4
4
 
5
- // The device-first transport mints a fallback token from the persisted
6
- // zero-cookie device credential via `refreshPersistedSession` (the ONE unified
7
- // refresh path) there is no `silentSignIn`/`signInWithSharedIdentity` arm
8
- // anymore. Mock that one core function so the transport's own coalescing /
9
- // logging contract can be exercised in isolation; keep the real `logger` +
10
- // memory store.
11
- jest.mock('@oxyhq/core', () => {
12
- const actual = jest.requireActual('@oxyhq/core');
13
- return { __esModule: true, ...actual, refreshPersistedSession: jest.fn() };
14
- });
15
-
16
- const mockedRefresh = refreshPersistedSession as jest.MockedFunction<typeof refreshPersistedSession>;
5
+ // The device-first transport no longer owns a private mint single-flight: it
6
+ // routes through the ONE shared `oxyServices.httpService.refreshAccessToken(...)`
7
+ // the scheduler/preflight/401 use, so concurrent lanes can never double-rotate
8
+ // the device secret. These tests exercise the transport's own no-op / error /
9
+ // swallow contract around that single call.
17
10
 
18
- function fakeOxy(accessToken: string | null) {
11
+ function fakeOxy(accessToken: string | null, refreshAccessToken = jest.fn(async () => 'minted-token')) {
19
12
  return {
20
13
  getAccessToken: jest.fn().mockReturnValue(accessToken),
14
+ httpService: { refreshAccessToken },
21
15
  };
22
16
  }
23
17
 
@@ -30,35 +24,33 @@ const state: DeviceSessionState = {
30
24
  };
31
25
 
32
26
  describe('createTokenTransport', () => {
33
- beforeEach(() => {
34
- jest.clearAllMocks();
35
- mockedRefresh.mockResolvedValue('minted-token');
36
- });
37
-
38
27
  test('no-ops when a token is already present', async () => {
39
- const oxy = fakeOxy('tok');
40
- const transport = createTokenTransport(oxy as never, createMemoryAuthStateStore());
28
+ const refreshAccessToken = jest.fn(async () => 'minted-token');
29
+ const oxy = fakeOxy('tok', refreshAccessToken);
30
+ const transport = createTokenTransport(oxy as never);
41
31
 
42
32
  await transport.ensureActiveToken(state);
43
33
 
44
- expect(mockedRefresh).not.toHaveBeenCalled();
34
+ expect(refreshAccessToken).not.toHaveBeenCalled();
45
35
  });
46
36
 
47
- test('mints via refreshPersistedSession when no token is present', async () => {
48
- const oxy = fakeOxy(null);
49
- const store = createMemoryAuthStateStore();
50
- const transport = createTokenTransport(oxy as never, store);
37
+ test('mints via the shared httpService.refreshAccessToken single-flight when no token is present', async () => {
38
+ const refreshAccessToken = jest.fn(async () => 'minted-token');
39
+ const oxy = fakeOxy(null, refreshAccessToken);
40
+ const transport = createTokenTransport(oxy as never);
51
41
 
52
42
  await transport.ensureActiveToken(state);
53
43
 
54
- expect(mockedRefresh).toHaveBeenCalledTimes(1);
55
- expect(mockedRefresh).toHaveBeenCalledWith({ oxy, store });
44
+ expect(refreshAccessToken).toHaveBeenCalledTimes(1);
45
+ expect(refreshAccessToken).toHaveBeenCalledWith('preflight');
56
46
  });
57
47
 
58
48
  test('resolves (never rejects) and logs a warning when the refresh throws', async () => {
59
- const oxy = fakeOxy(null);
60
- mockedRefresh.mockRejectedValue(new Error('mint boom'));
61
- const transport = createTokenTransport(oxy as never, createMemoryAuthStateStore());
49
+ const refreshAccessToken = jest.fn(async () => {
50
+ throw new Error('mint boom');
51
+ });
52
+ const oxy = fakeOxy(null, refreshAccessToken);
53
+ const transport = createTokenTransport(oxy as never);
62
54
  const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
63
55
 
64
56
  await expect(transport.ensureActiveToken(state)).resolves.toBeUndefined();
@@ -73,48 +65,39 @@ describe('createTokenTransport', () => {
73
65
  });
74
66
 
75
67
  test('resolves cleanly when the refresh produces no session', async () => {
76
- const oxy = fakeOxy(null);
77
- mockedRefresh.mockResolvedValue(null);
78
- const transport = createTokenTransport(oxy as never, createMemoryAuthStateStore());
68
+ const refreshAccessToken = jest.fn(async () => null);
69
+ const oxy = fakeOxy(null, refreshAccessToken);
70
+ const transport = createTokenTransport(oxy as never);
79
71
 
80
72
  await expect(transport.ensureActiveToken(state)).resolves.toBeUndefined();
81
- expect(mockedRefresh).toHaveBeenCalledTimes(1);
73
+ expect(refreshAccessToken).toHaveBeenCalledTimes(1);
82
74
  });
83
75
 
84
- test('coalesces concurrent calls into a single mint', async () => {
85
- const oxy = fakeOxy(null);
86
- let resolveMint: ((value: string | null) => void) | null = null;
87
- mockedRefresh.mockImplementation(
88
- () =>
89
- new Promise<string | null>((resolve) => {
90
- resolveMint = resolve;
91
- }),
92
- );
93
- const transport = createTokenTransport(oxy as never, createMemoryAuthStateStore());
76
+ test('delegates concurrent calls to the shared single-flight (no private guard of its own)', async () => {
77
+ // The transport keeps NO local coalescing — it forwards each call to the
78
+ // shared `httpService.refreshAccessToken`, which owns the single-flight that
79
+ // collapses concurrent mints into one server rotation (covered in core).
80
+ const refreshAccessToken = jest.fn(async () => 'minted-token');
81
+ const oxy = fakeOxy(null, refreshAccessToken);
82
+ const transport = createTokenTransport(oxy as never);
94
83
 
95
84
  const first = transport.ensureActiveToken(state);
96
85
  const second = transport.ensureActiveToken(state);
86
+ // Both concurrent callers reached the shared entry point synchronously — the
87
+ // transport does not swallow the second behind a private guard; dedup is the
88
+ // shared single-flight's job.
89
+ expect(refreshAccessToken).toHaveBeenCalledTimes(2);
97
90
 
98
- expect(mockedRefresh).toHaveBeenCalledTimes(1);
99
-
100
- resolveMint?.(null);
101
- await Promise.all([first, second]);
102
-
103
- expect(mockedRefresh).toHaveBeenCalledTimes(1);
104
-
105
- // A later call after the in-flight mint settled starts a fresh mint.
106
- const third = transport.ensureActiveToken(state);
107
- expect(mockedRefresh).toHaveBeenCalledTimes(2);
108
- resolveMint?.(null);
109
- await third;
91
+ await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]);
110
92
  });
111
93
 
112
94
  test('treats a throwing getAccessToken as no-token and still mints', async () => {
113
- const oxy = fakeOxy(null);
95
+ const refreshAccessToken = jest.fn(async () => 'minted-token');
96
+ const oxy = fakeOxy(null, refreshAccessToken);
114
97
  oxy.getAccessToken.mockImplementation(() => {
115
98
  throw new Error('storage unavailable');
116
99
  });
117
- const transport = createTokenTransport(oxy as never, createMemoryAuthStateStore());
100
+ const transport = createTokenTransport(oxy as never);
118
101
  const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
119
102
 
120
103
  await expect(transport.ensureActiveToken(state)).resolves.toBeUndefined();
@@ -124,7 +107,7 @@ describe('createTokenTransport', () => {
124
107
  { component: 'TokenTransport' },
125
108
  expect.any(Error),
126
109
  );
127
- expect(mockedRefresh).toHaveBeenCalledTimes(1);
110
+ expect(refreshAccessToken).toHaveBeenCalledTimes(1);
128
111
 
129
112
  warnSpy.mockRestore();
130
113
  });
@@ -2,8 +2,8 @@ import { io } from 'socket.io-client';
2
2
  import {
3
3
  SessionClient,
4
4
  createSessionClientHost,
5
- type AuthStateStore,
6
5
  type OxyServices,
6
+ type SessionStateOrigin,
7
7
  } from '@oxyhq/core';
8
8
  import { createTokenTransport } from './tokenTransport';
9
9
 
@@ -19,27 +19,30 @@ import { createTokenTransport } from './tokenTransport';
19
19
  * core's lazy dynamic import of a bare specifier — bundler-fragile in
20
20
  * Metro/Expo-web against the published core dist.
21
21
  * - the device-first {@link createTokenTransport}, which mints a fallback token
22
- * from the persisted device credential (`store` `deviceId` + `deviceSecret`).
22
+ * through the ONE shared `httpService.refreshAccessToken` single-flight (it
23
+ * reads the persisted `deviceId` + `deviceSecret` via the installed handler,
24
+ * so it takes no `store` of its own).
23
25
  *
24
- * `onUnauthenticated` fires when an applied device state has zero accounts (a
25
- * device signout-all): the provider clears the persisted store + local state so
26
- * a reload does not try to restore a dead session. The host is returned
27
- * alongside the client so the caller can call `host.setCurrentAccountId(...)`
28
- * as the active account changes.
26
+ * `onUnauthenticated` fires when an applied device state has zero accounts. It
27
+ * receives the {@link SessionStateOrigin} so the provider can gate the
28
+ * destructive credential wipe: a `request`-origin verdict (a REST sign-out /
29
+ * revocation) clears the persisted store; a `push`-origin verdict (a possibly
30
+ * transient socket broadcast) clears only the local UI session and KEEPS the
31
+ * durable device credential. The host is returned alongside the client so the
32
+ * caller can call `host.setCurrentAccountId(...)` as the active account changes.
29
33
  *
30
34
  * The realtime socket uses bearer when authenticated, or deviceId+deviceSecret
31
35
  * when signed out (after the one-shot join redirect).
32
36
  */
33
37
  export function createSessionClient(
34
38
  oxyServices: OxyServices,
35
- store: AuthStateStore,
36
- onUnauthenticated?: () => void,
39
+ onUnauthenticated?: (origin: SessionStateOrigin) => void,
37
40
  ): {
38
41
  client: SessionClient;
39
42
  host: ReturnType<typeof createSessionClientHost>;
40
43
  } {
41
44
  const host = createSessionClientHost(oxyServices);
42
- const transport = createTokenTransport(oxyServices, store);
45
+ const transport = createTokenTransport(oxyServices);
43
46
  const client = new SessionClient(host, { transport, socketFactory: io, onUnauthenticated });
44
47
  return { client, host };
45
48
  }
@@ -1,10 +1,4 @@
1
- import {
2
- refreshPersistedSession,
3
- logger,
4
- type AuthStateStore,
5
- type OxyServices,
6
- type TokenTransport,
7
- } from '@oxyhq/core';
1
+ import { logger, type OxyServices, type TokenTransport } from '@oxyhq/core';
8
2
  import type { DeviceSessionState } from '@oxyhq/contracts';
9
3
 
10
4
  /**
@@ -12,21 +6,18 @@ import type { DeviceSessionState } from '@oxyhq/contracts';
12
6
  *
13
7
  * `ensureActiveToken` is the fallback the client uses when a `session_state`
14
8
  * push arrived WITHOUT an embedded `activeToken` and the app currently holds no
15
- * bearer. It mints one through the ONE unified refresh path
16
- * (`refreshPersistedSession`), which presents the persisted zero-cookie device
17
- * credential (`deviceId` + `deviceSecret`) at `POST /session/device/token`. The
18
- * persisted device credential is the sole durable restore credential.
9
+ * bearer. It mints one through the ONE unified single-flight the scheduler, the
10
+ * request-time preflight, and the 401 retry all use —
11
+ * `oxyServices.httpService.refreshAccessToken(...)` which runs the installed
12
+ * refresh handler (present the persisted zero-cookie `deviceId` + `deviceSecret`
13
+ * at `POST /session/device/token`). Routing through the SAME entry point means
14
+ * this lane can never double-rotate the device secret against the others: it has
15
+ * NO private single-flight of its own.
19
16
  *
20
- * Concurrent pushes coalesce onto one in-flight mint. A failure is logged and
21
- * swallowed: this method must never throw out (it runs inside the socket state
22
- * handler).
17
+ * A failure is logged and swallowed: this method must never throw out (it runs
18
+ * inside the socket state handler).
23
19
  */
24
- export function createTokenTransport(
25
- oxyServices: OxyServices,
26
- store: AuthStateStore,
27
- ): TokenTransport {
28
- let inFlightMint: Promise<void> | null = null;
29
-
20
+ export function createTokenTransport(oxyServices: OxyServices): TokenTransport {
30
21
  return {
31
22
  async ensureActiveToken(state: DeviceSessionState): Promise<void> {
32
23
  try {
@@ -37,28 +28,18 @@ export function createTokenTransport(
37
28
  logger.warn('ensureActiveToken: getAccessToken threw', { component: 'TokenTransport' }, error);
38
29
  }
39
30
 
40
- if (inFlightMint) {
41
- return inFlightMint;
42
- }
43
-
44
- inFlightMint = (async () => {
45
- try {
46
- const token = await refreshPersistedSession({ oxy: oxyServices, store });
47
- if (!token) {
48
- logger.debug('ensureActiveToken: refresh produced no session', {
49
- component: 'TokenTransport',
50
- deviceId: state.deviceId,
51
- });
52
- }
53
- } catch (error) {
54
- logger.warn('ensureActiveToken: refresh failed', { component: 'TokenTransport' }, error);
55
- }
56
- })();
57
-
58
31
  try {
59
- await inFlightMint;
60
- } finally {
61
- inFlightMint = null;
32
+ // The shared HttpService single-flight coalesces concurrent callers onto
33
+ // one in-flight mint; no local `inFlightMint` guard is needed.
34
+ const token = await oxyServices.httpService.refreshAccessToken('preflight');
35
+ if (!token) {
36
+ logger.debug('ensureActiveToken: refresh produced no session', {
37
+ component: 'TokenTransport',
38
+ deviceId: state.deviceId,
39
+ });
40
+ }
41
+ } catch (error) {
42
+ logger.warn('ensureActiveToken: refresh failed', { component: 'TokenTransport' }, error);
62
43
  }
63
44
  },
64
45
  };
@@ -97,5 +97,34 @@ function stripOAuthParamsFromUrl(): void {
97
97
  const url = new URL(location.href);
98
98
  url.searchParams.delete('code');
99
99
  url.searchParams.delete('state');
100
+ url.searchParams.delete('error');
101
+ url.searchParams.delete('error_description');
102
+ url.searchParams.delete('hub_sync');
100
103
  history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`);
101
104
  }
105
+
106
+ /**
107
+ * When auth.oxy.so hub-sync redeem fails, the IdP redirects back with
108
+ * `?hub_sync=failed`. Strip the param and log so cold boot can continue
109
+ * without leaving a stale query string.
110
+ */
111
+ export function consumeHubSyncFailure(): boolean {
112
+ if (!isWebBrowser()) return false;
113
+ const location = (globalThis as { location?: Location; history?: History }).location;
114
+ const history = (globalThis as { history?: History }).history;
115
+ if (!location) return false;
116
+
117
+ const params = new URLSearchParams(location.search);
118
+ if (params.get('hub_sync') !== 'failed') return false;
119
+
120
+ logger.warn('Hub sync failed — IdP device session may be out of sync', {
121
+ component: 'hubSync',
122
+ });
123
+
124
+ if (history?.replaceState) {
125
+ const url = new URL(location.href);
126
+ url.searchParams.delete('hub_sync');
127
+ history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`);
128
+ }
129
+ return true;
130
+ }