@oxyhq/services 19.1.2 → 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.
- package/lib/commonjs/index.js +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +53 -50
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/commitSessionFlow.js +91 -0
- package/lib/commonjs/ui/context/commitSessionFlow.js.map +1 -0
- package/lib/commonjs/ui/hooks/useOxyEvent.js +28 -0
- package/lib/commonjs/ui/hooks/useOxyEvent.js.map +1 -0
- package/lib/commonjs/ui/screens/FileManagementScreen.js +3 -9
- package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
- package/lib/commonjs/ui/session/createSessionClient.js +12 -8
- package/lib/commonjs/ui/session/createSessionClient.js.map +1 -1
- package/lib/commonjs/ui/session/tokenTransport.js +22 -32
- package/lib/commonjs/ui/session/tokenTransport.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +54 -51
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/commitSessionFlow.js +87 -0
- package/lib/module/ui/context/commitSessionFlow.js.map +1 -0
- package/lib/module/ui/hooks/useOxyEvent.js +25 -0
- package/lib/module/ui/hooks/useOxyEvent.js.map +1 -0
- package/lib/module/ui/screens/FileManagementScreen.js +3 -9
- package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
- package/lib/module/ui/session/createSessionClient.js +12 -8
- package/lib/module/ui/session/createSessionClient.js.map +1 -1
- package/lib/module/ui/session/tokenTransport.js +23 -33
- package/lib/module/ui/session/tokenTransport.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts +39 -0
- package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +3 -1
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useOxyEvent.d.ts +8 -0
- package/lib/typescript/commonjs/ui/hooks/useOxyEvent.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +12 -8
- package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +11 -9
- package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/commitSessionFlow.d.ts +39 -0
- package/lib/typescript/module/ui/context/commitSessionFlow.d.ts.map +1 -0
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +3 -1
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useOxyEvent.d.ts +8 -0
- package/lib/typescript/module/ui/hooks/useOxyEvent.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/createSessionClient.d.ts +12 -8
- package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/tokenTransport.d.ts +11 -9
- package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -1
- package/package.json +16 -16
- package/src/index.ts +1 -0
- package/src/ui/context/OxyContext.tsx +51 -49
- package/src/ui/context/__tests__/commitSessionFlow.test.ts +192 -0
- package/src/ui/context/commitSessionFlow.ts +115 -0
- package/src/ui/context/oxyContextTypes.ts +3 -1
- package/src/ui/hooks/useOxyEvent.ts +22 -0
- package/src/ui/screens/FileManagementScreen.tsx +3 -9
- package/src/ui/session/__tests__/createSessionClient.test.ts +5 -5
- package/src/ui/session/__tests__/tokenTransport.test.ts +43 -60
- package/src/ui/session/createSessionClient.ts +13 -10
- package/src/ui/session/tokenTransport.ts +22 -41
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { User } from '@oxyhq/core';
|
|
2
|
+
import { logger } from '@oxyhq/core';
|
|
3
|
+
import {
|
|
4
|
+
commitDeviceSetAndResolve,
|
|
5
|
+
type CommitDeviceSetAndResolveDeps,
|
|
6
|
+
} from '../commitSessionFlow';
|
|
7
|
+
|
|
8
|
+
// A resolved promise chain (addCurrentAccount -> start -> syncFromClient) settles
|
|
9
|
+
// entirely within the microtask queue, which is fully drained before a macrotask
|
|
10
|
+
// (`setTimeout`) runs — so one tick flushes a detached reconcile task.
|
|
11
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
12
|
+
|
|
13
|
+
const makeUser = (id: string): User => ({ id, username: id } as unknown as User);
|
|
14
|
+
|
|
15
|
+
interface Deferred<T> {
|
|
16
|
+
promise: Promise<T>;
|
|
17
|
+
resolve: (value: T) => void;
|
|
18
|
+
reject: (reason?: unknown) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const deferred = <T,>(): Deferred<T> => {
|
|
22
|
+
let resolve: (value: T) => void = () => undefined;
|
|
23
|
+
let reject: (reason?: unknown) => void = () => undefined;
|
|
24
|
+
const promise = new Promise<T>((res, rej) => {
|
|
25
|
+
resolve = res;
|
|
26
|
+
reject = rej;
|
|
27
|
+
});
|
|
28
|
+
return { promise, resolve, reject };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a deps object whose every step appends to a shared `order` log, so tests
|
|
33
|
+
* can assert both WHICH steps ran and the RELATIVE ordering between the
|
|
34
|
+
* auth-resolution gate and the device-set reconcile.
|
|
35
|
+
*/
|
|
36
|
+
const buildDeps = (
|
|
37
|
+
order: string[],
|
|
38
|
+
overrides: Partial<CommitDeviceSetAndResolveDeps> = {},
|
|
39
|
+
): CommitDeviceSetAndResolveDeps => ({
|
|
40
|
+
activate: false,
|
|
41
|
+
userId: 'u1',
|
|
42
|
+
fallbackUser: makeUser('fallback'),
|
|
43
|
+
registerAndActivate: jest.fn(async () => {
|
|
44
|
+
order.push('registerAndActivate');
|
|
45
|
+
}),
|
|
46
|
+
addCurrentAccount: jest.fn(async () => {
|
|
47
|
+
order.push('addCurrentAccount');
|
|
48
|
+
}),
|
|
49
|
+
startSocket: jest.fn(async () => {
|
|
50
|
+
order.push('startSocket');
|
|
51
|
+
}),
|
|
52
|
+
syncFromClient: jest.fn(async () => {
|
|
53
|
+
order.push('syncFromClient');
|
|
54
|
+
}),
|
|
55
|
+
getCurrentUser: jest.fn(async () => {
|
|
56
|
+
order.push('getCurrentUser');
|
|
57
|
+
return makeUser('u1');
|
|
58
|
+
}),
|
|
59
|
+
loginSuccess: jest.fn(() => {
|
|
60
|
+
order.push('loginSuccess');
|
|
61
|
+
}),
|
|
62
|
+
onAuthStateChange: jest.fn(() => {
|
|
63
|
+
order.push('onAuthStateChange');
|
|
64
|
+
}),
|
|
65
|
+
markAuthResolved: jest.fn(() => {
|
|
66
|
+
order.push('markAuthResolved');
|
|
67
|
+
}),
|
|
68
|
+
...overrides,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('commitDeviceSetAndResolve — cold boot (activate: false)', () => {
|
|
72
|
+
it('resolves auth from getCurrentUser BEFORE the device-set reconcile', async () => {
|
|
73
|
+
const order: string[] = [];
|
|
74
|
+
const deps = buildDeps(order);
|
|
75
|
+
|
|
76
|
+
await commitDeviceSetAndResolve(deps);
|
|
77
|
+
await flush();
|
|
78
|
+
|
|
79
|
+
// loginSuccess + onAuthStateChange + markAuthResolved fire after getCurrentUser.
|
|
80
|
+
expect(deps.loginSuccess).toHaveBeenCalledWith(makeUser('u1'));
|
|
81
|
+
expect(deps.onAuthStateChange).toHaveBeenCalledWith(makeUser('u1'));
|
|
82
|
+
expect(deps.markAuthResolved).toHaveBeenCalledTimes(1);
|
|
83
|
+
|
|
84
|
+
expect(order.indexOf('getCurrentUser')).toBeLessThan(order.indexOf('loginSuccess'));
|
|
85
|
+
expect(order.indexOf('loginSuccess')).toBeLessThan(order.indexOf('markAuthResolved'));
|
|
86
|
+
|
|
87
|
+
// The reconcile ran (via addCurrentAccount, NOT registerAndActivate) but only
|
|
88
|
+
// AFTER the auth gate flipped — proving it did not block the resolve.
|
|
89
|
+
expect(deps.addCurrentAccount).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(deps.registerAndActivate).not.toHaveBeenCalled();
|
|
91
|
+
expect(order.indexOf('markAuthResolved')).toBeLessThan(order.indexOf('addCurrentAccount'));
|
|
92
|
+
expect(order.indexOf('addCurrentAccount')).toBeLessThan(order.indexOf('startSocket'));
|
|
93
|
+
expect(order.indexOf('startSocket')).toBeLessThan(order.indexOf('syncFromClient'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('still resolves auth even when the device-set reconcile hangs forever', async () => {
|
|
97
|
+
const order: string[] = [];
|
|
98
|
+
const hang = deferred<void>(); // addCurrentAccount never settles
|
|
99
|
+
const deps = buildDeps(order, {
|
|
100
|
+
addCurrentAccount: jest.fn(() => {
|
|
101
|
+
order.push('addCurrentAccount');
|
|
102
|
+
return hang.promise;
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Must resolve without waiting on the hung reconcile.
|
|
107
|
+
await commitDeviceSetAndResolve(deps);
|
|
108
|
+
|
|
109
|
+
expect(deps.markAuthResolved).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(deps.loginSuccess).toHaveBeenCalledWith(makeUser('u1'));
|
|
111
|
+
expect(deps.addCurrentAccount).toHaveBeenCalledTimes(1);
|
|
112
|
+
// The hung step gates the rest of the chain — never reached.
|
|
113
|
+
expect(deps.startSocket).not.toHaveBeenCalled();
|
|
114
|
+
expect(deps.syncFromClient).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('falls back to the minimal commit-input user when the profile fetch fails', async () => {
|
|
118
|
+
const order: string[] = [];
|
|
119
|
+
const deps = buildDeps(order, {
|
|
120
|
+
getCurrentUser: jest.fn(async () => {
|
|
121
|
+
throw new Error('offline');
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await commitDeviceSetAndResolve(deps);
|
|
126
|
+
await flush();
|
|
127
|
+
|
|
128
|
+
expect(deps.loginSuccess).toHaveBeenCalledWith(makeUser('fallback'));
|
|
129
|
+
expect(deps.markAuthResolved).toHaveBeenCalledTimes(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('logs (never swallows) a failing background reconcile and still resolves', async () => {
|
|
133
|
+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
134
|
+
const order: string[] = [];
|
|
135
|
+
const failure = new Error('device-set boom');
|
|
136
|
+
const deps = buildDeps(order, {
|
|
137
|
+
addCurrentAccount: jest.fn(async () => {
|
|
138
|
+
throw failure;
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await commitDeviceSetAndResolve(deps);
|
|
143
|
+
await flush();
|
|
144
|
+
|
|
145
|
+
expect(deps.markAuthResolved).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
147
|
+
'commitSession: device-set registration failed',
|
|
148
|
+
{ component: 'OxyContext', method: 'commitSession' },
|
|
149
|
+
failure,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('commitDeviceSetAndResolve — deliberate sign-in (activate: true)', () => {
|
|
155
|
+
it('BLOCKS on the device-set reconcile before resolving auth (unchanged ordering)', async () => {
|
|
156
|
+
const order: string[] = [];
|
|
157
|
+
const gate = deferred<void>(); // registerAndActivate stays pending
|
|
158
|
+
const deps = buildDeps(order, {
|
|
159
|
+
activate: true,
|
|
160
|
+
registerAndActivate: jest.fn(() => {
|
|
161
|
+
order.push('registerAndActivate');
|
|
162
|
+
return gate.promise;
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const settled = jest.fn();
|
|
167
|
+
const pending = commitDeviceSetAndResolve(deps).then(settled);
|
|
168
|
+
|
|
169
|
+
// While the reconcile is pending, auth must NOT resolve yet.
|
|
170
|
+
await flush();
|
|
171
|
+
expect(deps.registerAndActivate).toHaveBeenCalledTimes(1);
|
|
172
|
+
expect(deps.addCurrentAccount).not.toHaveBeenCalled();
|
|
173
|
+
expect(deps.getCurrentUser).not.toHaveBeenCalled();
|
|
174
|
+
expect(deps.markAuthResolved).not.toHaveBeenCalled();
|
|
175
|
+
expect(settled).not.toHaveBeenCalled();
|
|
176
|
+
|
|
177
|
+
// Complete the reconcile → hydrate + resolve now runs.
|
|
178
|
+
gate.resolve();
|
|
179
|
+
await pending;
|
|
180
|
+
|
|
181
|
+
expect(order).toEqual([
|
|
182
|
+
'registerAndActivate',
|
|
183
|
+
'startSocket',
|
|
184
|
+
'syncFromClient',
|
|
185
|
+
'getCurrentUser',
|
|
186
|
+
'loginSuccess',
|
|
187
|
+
'onAuthStateChange',
|
|
188
|
+
'markAuthResolved',
|
|
189
|
+
]);
|
|
190
|
+
expect(settled).toHaveBeenCalledTimes(1);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { User } from '@oxyhq/core';
|
|
2
|
+
import { logger as loggerUtil } from '@oxyhq/core';
|
|
3
|
+
|
|
4
|
+
const LOG_CONTEXT = { component: 'OxyContext', method: 'commitSession' } as const;
|
|
5
|
+
|
|
6
|
+
export interface CommitDeviceSetAndResolveDeps {
|
|
7
|
+
/**
|
|
8
|
+
* Whether this commit is a deliberate sign-in on THIS device (activates the
|
|
9
|
+
* account and BLOCKS on the device-set reconcile before resolving) vs. a
|
|
10
|
+
* cold-boot restore (resolves auth from the profile fetch FIRST, then
|
|
11
|
+
* reconciles the device set in the background).
|
|
12
|
+
*/
|
|
13
|
+
activate: boolean;
|
|
14
|
+
/** The committing account id (only used by `registerAndActivate`). */
|
|
15
|
+
userId?: string;
|
|
16
|
+
/** Minimal user carried by the commit input; used if the profile fetch fails. */
|
|
17
|
+
fallbackUser: User | null;
|
|
18
|
+
/** Deliberate sign-in: register + activate the account in the device set. */
|
|
19
|
+
registerAndActivate: (userId?: string) => Promise<void>;
|
|
20
|
+
/** Cold boot: ensure membership only; the server's `activeAccountId` wins. */
|
|
21
|
+
addCurrentAccount: () => Promise<void>;
|
|
22
|
+
/** Start the device-scoped socket. */
|
|
23
|
+
startSocket: () => Promise<void>;
|
|
24
|
+
/** Project the server-authoritative device state onto local sessions. */
|
|
25
|
+
syncFromClient: () => Promise<void>;
|
|
26
|
+
/** Hydrate the full profile (the commit input carries only minimal data). */
|
|
27
|
+
getCurrentUser: () => Promise<User | null>;
|
|
28
|
+
loginSuccess: (user: User) => void;
|
|
29
|
+
onAuthStateChange?: (user: User | null) => void;
|
|
30
|
+
markAuthResolved: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Device-set registration + full-user hydration + auth-resolution gate flip.
|
|
35
|
+
*
|
|
36
|
+
* On a deliberate sign-in (`activate: true`) the device-set reconcile is awaited
|
|
37
|
+
* BEFORE hydrating and resolving — unchanged legacy ordering. On a cold boot
|
|
38
|
+
* (`activate: false`) auth resolves from the profile fetch FIRST so the app can
|
|
39
|
+
* paint an authenticated shell without waiting on device-set registration, the
|
|
40
|
+
* socket connect, and the authoritative sessions projection — those reconcile in
|
|
41
|
+
* a DETACHED background task a moment later.
|
|
42
|
+
*/
|
|
43
|
+
export async function commitDeviceSetAndResolve(
|
|
44
|
+
deps: CommitDeviceSetAndResolveDeps,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const {
|
|
47
|
+
activate,
|
|
48
|
+
userId,
|
|
49
|
+
fallbackUser,
|
|
50
|
+
registerAndActivate,
|
|
51
|
+
addCurrentAccount,
|
|
52
|
+
startSocket,
|
|
53
|
+
syncFromClient,
|
|
54
|
+
getCurrentUser,
|
|
55
|
+
loginSuccess,
|
|
56
|
+
onAuthStateChange,
|
|
57
|
+
markAuthResolved,
|
|
58
|
+
} = deps;
|
|
59
|
+
|
|
60
|
+
// Register into the device set, start the socket, then project server truth. A
|
|
61
|
+
// deliberate sign-in ACTIVATES the account (`registerAndActivate`); the cold
|
|
62
|
+
// boot only ensures membership and lets the server's own `activeAccountId` win
|
|
63
|
+
// (`addCurrentAccount`).
|
|
64
|
+
const reconcileDeviceSet = async (): Promise<void> => {
|
|
65
|
+
if (activate) {
|
|
66
|
+
await registerAndActivate(userId);
|
|
67
|
+
} else {
|
|
68
|
+
await addCurrentAccount();
|
|
69
|
+
}
|
|
70
|
+
await startSocket();
|
|
71
|
+
await syncFromClient();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const logReconcileError = (registrationError: unknown): void => {
|
|
75
|
+
loggerUtil.warn('commitSession: device-set registration failed', LOG_CONTEXT, registrationError);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Hydrate the full user then flip the auth-resolution gate. Falls back to the
|
|
79
|
+
// minimal commit-input shape if the profile fetch fails — never leaves auth
|
|
80
|
+
// unresolved.
|
|
81
|
+
const hydrateAndResolve = async (): Promise<void> => {
|
|
82
|
+
let fullUser: User | null = null;
|
|
83
|
+
try {
|
|
84
|
+
fullUser = await getCurrentUser();
|
|
85
|
+
} catch (profileError) {
|
|
86
|
+
if (__DEV__) {
|
|
87
|
+
loggerUtil.debug('Failed to fetch full user on commit; using minimal fallback', LOG_CONTEXT, profileError);
|
|
88
|
+
}
|
|
89
|
+
fullUser = fallbackUser;
|
|
90
|
+
}
|
|
91
|
+
if (fullUser) {
|
|
92
|
+
loginSuccess(fullUser);
|
|
93
|
+
onAuthStateChange?.(fullUser);
|
|
94
|
+
}
|
|
95
|
+
markAuthResolved();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (activate) {
|
|
99
|
+
// Deliberate sign-in: block on the device-set reconcile (unchanged ordering)
|
|
100
|
+
// before hydrating + resolving, so the account is fully registered/active.
|
|
101
|
+
try {
|
|
102
|
+
await reconcileDeviceSet();
|
|
103
|
+
} catch (registrationError) {
|
|
104
|
+
logReconcileError(registrationError);
|
|
105
|
+
}
|
|
106
|
+
await hydrateAndResolve();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Cold boot: resolve auth from the profile fetch FIRST; reconcile the device
|
|
111
|
+
// set (membership + socket + sessions projection) in a DETACHED background
|
|
112
|
+
// task so first paint is not blocked behind those round-trips.
|
|
113
|
+
await hydrateAndResolve();
|
|
114
|
+
void reconcileDeviceSet().catch(logReconcileError);
|
|
115
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import type { OxyServices, User, SessionLoginResponse, AccountNode, CreateAccountInput, ClientSession, AccountDialogController, AccountDialogView, ApiError } from '@oxyhq/core';
|
|
2
|
+
import type { OxyServices, User, SessionLoginResponse, AccountNode, CreateAccountInput, ClientSession, AccountDialogController, AccountDialogView, ApiError, SessionClient } from '@oxyhq/core';
|
|
3
3
|
import type { SecurityAlert } from '@oxyhq/contracts';
|
|
4
4
|
import type { UseFollowHook } from '../hooks/useFollow.types';
|
|
5
5
|
import type { useLanguageManagement } from '../hooks/useLanguageManagement';
|
|
@@ -76,6 +76,8 @@ export interface OxyContextState {
|
|
|
76
76
|
storageKeyPrefix: string;
|
|
77
77
|
clientId: string | null;
|
|
78
78
|
oxyServices: OxyServices;
|
|
79
|
+
/** Server-authoritative device session client. `null` before an `OxyProvider` is mounted. */
|
|
80
|
+
sessionClient: SessionClient | null;
|
|
79
81
|
useFollow?: UseFollowHook;
|
|
80
82
|
showBottomSheet?: (screenOrConfig: RouteName | { screen: RouteName; props?: Record<string, unknown> }) => void;
|
|
81
83
|
openAvatarPicker: () => void;
|
|
@@ -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
|
+
}
|
|
@@ -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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
2
|
+
import { logger } from '@oxyhq/core';
|
|
3
3
|
import { createTokenTransport } from '../tokenTransport';
|
|
4
4
|
|
|
5
|
-
// The device-first transport
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
40
|
-
const
|
|
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(
|
|
34
|
+
expect(refreshAccessToken).not.toHaveBeenCalled();
|
|
45
35
|
});
|
|
46
36
|
|
|
47
|
-
test('mints via
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const transport = createTokenTransport(oxy as never
|
|
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(
|
|
55
|
-
expect(
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
const transport = createTokenTransport(oxy as never
|
|
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(
|
|
73
|
+
expect(refreshAccessToken).toHaveBeenCalledTimes(1);
|
|
82
74
|
});
|
|
83
75
|
|
|
84
|
-
test('
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
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
|
|
45
|
+
const transport = createTokenTransport(oxyServices);
|
|
43
46
|
const client = new SessionClient(host, { transport, socketFactory: io, onUnauthenticated });
|
|
44
47
|
return { client, host };
|
|
45
48
|
}
|