@oxyhq/services 19.1.2 → 19.2.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.
- package/android/build.gradle +21 -0
- package/android/src/main/AndroidManifest.xml +10 -0
- package/android/src/main/java/so/oxy/identity/OxyIdentityModule.kt +84 -0
- package/android/src/main/java/so/oxy/identity/OxyIdentityProvider.kt +77 -0
- package/android/src/main/java/so/oxy/identity/OxyIdentityStore.kt +76 -0
- package/expo-module.config.json +9 -0
- package/ios/OxyIdentity.podspec +28 -0
- package/ios/OxyIdentityModule.swift +33 -0
- 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 +29 -17
- package/plugins/withSharedIdentityProvider.js +83 -0
- package/plugins/withSharedIdentityReader.js +46 -0
- 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
|
@@ -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
|
}
|
|
@@ -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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
};
|