@oxyhq/services 18.1.0 → 19.0.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 (51) hide show
  1. package/lib/commonjs/ui/context/OxyContext.js +49 -93
  2. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  3. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +15 -29
  4. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  5. package/lib/commonjs/ui/session/authStore.js +12 -50
  6. package/lib/commonjs/ui/session/authStore.js.map +1 -1
  7. package/lib/commonjs/ui/session/createSessionClient.js +6 -11
  8. package/lib/commonjs/ui/session/createSessionClient.js.map +1 -1
  9. package/lib/commonjs/ui/session/tokenTransport.js +3 -4
  10. package/lib/commonjs/ui/session/tokenTransport.js.map +1 -1
  11. package/lib/module/ui/context/OxyContext.js +49 -93
  12. package/lib/module/ui/context/OxyContext.js.map +1 -1
  13. package/lib/module/ui/context/hooks/useAuthOperations.js +15 -29
  14. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  15. package/lib/module/ui/session/authStore.js +13 -51
  16. package/lib/module/ui/session/authStore.js.map +1 -1
  17. package/lib/module/ui/session/createSessionClient.js +6 -11
  18. package/lib/module/ui/session/createSessionClient.js.map +1 -1
  19. package/lib/module/ui/session/tokenTransport.js +3 -4
  20. package/lib/module/ui/session/tokenTransport.js.map +1 -1
  21. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +15 -6
  22. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +4 -5
  24. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/ui/session/authStore.d.ts +11 -15
  27. package/lib/typescript/commonjs/ui/session/authStore.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +6 -10
  29. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +3 -4
  31. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -1
  32. package/lib/typescript/module/ui/context/OxyContext.d.ts +15 -6
  33. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  34. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +4 -5
  35. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  36. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  37. package/lib/typescript/module/ui/session/authStore.d.ts +11 -15
  38. package/lib/typescript/module/ui/session/authStore.d.ts.map +1 -1
  39. package/lib/typescript/module/ui/session/createSessionClient.d.ts +6 -10
  40. package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -1
  41. package/lib/typescript/module/ui/session/tokenTransport.d.ts +3 -4
  42. package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -1
  43. package/package.json +4 -4
  44. package/src/ui/context/OxyContext.tsx +57 -100
  45. package/src/ui/context/__tests__/OxyContext.coldBoot.test.tsx +13 -12
  46. package/src/ui/context/hooks/useAuthOperations.ts +18 -27
  47. package/src/ui/session/__tests__/createSessionClient.test.ts +3 -1
  48. package/src/ui/session/__tests__/tokenTransport.test.ts +6 -5
  49. package/src/ui/session/authStore.ts +12 -55
  50. package/src/ui/session/createSessionClient.ts +5 -10
  51. package/src/ui/session/tokenTransport.ts +3 -4
@@ -2,11 +2,11 @@
2
2
  * `OxyContextProvider` cold-boot opt-out (IdP mode).
3
3
  *
4
4
  * The provider is the ecosystem's device-first session authority by default:
5
- * it runs `runSessionColdBoot` on mount and opens the signed-out device-state
6
- * socket. The IdP host (`auth.oxy.so`) is NOT a session authority, so it mounts
7
- * the provider with `coldBoot={false}` — the cold boot must never run and the
8
- * device socket must never open, yet interactive sign-in must still commit a
9
- * normal session on that origin.
5
+ * it runs `runSessionColdBoot` on mount. The IdP host (`auth.oxy.so`) is NOT a
6
+ * session authority, so it mounts the provider with `coldBoot={false}` the
7
+ * cold boot must never run (so the bearer-authenticated device socket never
8
+ * starts), yet interactive sign-in must still commit a normal session on that
9
+ * origin.
10
10
  */
11
11
  import { render, act, waitFor } from '@testing-library/react';
12
12
  import type { ReactNode } from 'react';
@@ -105,7 +105,8 @@ describe('OxyContextProvider coldBoot opt-out (IdP mode)', () => {
105
105
  // No boot spinner: private-API readiness settles to a definitive "no".
106
106
  expect(getState()?.isPrivateApiPending).toBe(false);
107
107
  expect(getState()?.canUsePrivateApi).toBe(false);
108
- // The signed-out device-state socket must never open in IdP mode.
108
+ // IdP mode never runs the cold boot and never signs in, so the device
109
+ // socket is never started.
109
110
  expect(lastSessionClient().start).not.toHaveBeenCalled();
110
111
  });
111
112
 
@@ -116,7 +117,7 @@ describe('OxyContextProvider coldBoot opt-out (IdP mode)', () => {
116
117
  deviceId: 'd-1',
117
118
  expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
118
119
  accessToken: 'access-1',
119
- refreshToken: 'refresh-1',
120
+ deviceSecret: 'ds-1',
120
121
  user: { id: 'u-1', username: 'alice' },
121
122
  };
122
123
  jest.spyOn(oxy, 'passwordSignIn').mockResolvedValue(loginResult);
@@ -144,11 +145,11 @@ describe('OxyContextProvider coldBoot opt-out (IdP mode)', () => {
144
145
  expect(getState()?.isAuthenticated).toBe(true);
145
146
  });
146
147
  expect(getState()?.user?.id).toBe('u-1');
147
- expect(oxy.passwordSignIn).toHaveBeenCalledWith(
148
- 'alice',
149
- 'secret',
150
- expect.objectContaining({ deviceToken: undefined }),
151
- );
148
+ // Zero-cookie cutover: no `deviceToken` is threaded into `passwordSignIn`.
149
+ expect(oxy.passwordSignIn).toHaveBeenCalledWith('alice', 'secret', {
150
+ deviceName: undefined,
151
+ deviceFingerprint: undefined,
152
+ });
152
153
  });
153
154
 
154
155
  it('default (coldBoot omitted): runs the device-first cold boot', async () => {
@@ -15,10 +15,9 @@ export interface UseAuthOperationsOptions {
15
15
  /**
16
16
  * The device-first persisted auth-state store. On EXPLICIT full sign-out the
17
17
  * session blob is cleared (`store.clear()`) so a reload's cold boot finds no
18
- * refresh family to restore; on sign-in a returned rotating refresh token is
19
- * persisted so the next boot warm-restores without a redirect. The long-lived
20
- * device token SURVIVES sign-out (the device is unchanged), so it is never
21
- * cleared here.
18
+ * device credential to restore; on sign-in the returned zero-cookie device
19
+ * credential (`deviceId` + `deviceSecret`) is persisted so the next boot
20
+ * warm-restores without a redirect.
22
21
  */
23
22
  store: AuthStateStore;
24
23
  activeSessionId: string | null;
@@ -61,7 +60,7 @@ const LOGOUT_ERROR_CODE = 'LOGOUT_ERROR';
61
60
  const LOGOUT_ALL_ERROR_CODE = 'LOGOUT_ALL_ERROR';
62
61
 
63
62
  /**
64
- * Clear the persisted refresh-token family on an explicit full sign-out.
63
+ * Clear the persisted device credential on an explicit full sign-out.
65
64
  * Best-effort and non-blocking: sign-out must never fail because a storage
66
65
  * write threw. Exported so `OxyContext`'s zero-account branch (a REMOTE full
67
66
  * sign-out pushed over the socket) runs the EXACT same cleanup as the local
@@ -136,29 +135,21 @@ export const useAuthOperations = ({
136
135
  deviceFingerprint,
137
136
  );
138
137
 
139
- // Persist the per-origin refresh family so a reload warm-restores this
138
+ // Persist the durable device credential so a reload warm-restores this
140
139
  // session without a redirect. `verifyChallenge` plants the access token
141
- // internally; when the (trusted-lane) response also carries a rotating
142
- // refresh token, persist the durable blob. Best-effort — a failed persist
143
- // never fails the sign-in (native additionally re-mints via the shared
144
- // keychain on the next cold boot). `SessionLoginResponse` does not type
145
- // `refreshToken`; read it defensively from the runtime payload.
146
- const refreshToken = (sessionResponse as { refreshToken?: string }).refreshToken;
147
- if (refreshToken) {
140
+ // internally; when the response also carries the zero-cookie device
141
+ // credential (`deviceId` + `deviceSecret`), persist the durable blob so the
142
+ // next cold boot re-mints an access token from it. Best-effort a failed
143
+ // persist never fails the sign-in. `SessionLoginResponse` does not type
144
+ // `deviceSecret`; read it defensively from the runtime payload.
145
+ const deviceSecret = (sessionResponse as { deviceSecret?: string }).deviceSecret;
146
+ if (sessionResponse.deviceId && deviceSecret) {
148
147
  try {
149
- const deviceToken = (await store.loadDeviceToken()) ?? undefined;
150
- // Phase 2c: mirror the refreshToken handling for the rotating
151
- // deviceSecret — persist it with the response's deviceId so the next
152
- // cold boot restores via the zero-cookie mint. `SessionLoginResponse`
153
- // types neither; read the secret defensively from the runtime payload.
154
- const deviceSecret = (sessionResponse as { deviceSecret?: string }).deviceSecret;
155
148
  await store.save({
156
149
  sessionId: sessionResponse.sessionId,
157
- refreshToken,
158
150
  userId: sessionResponse.user.id,
159
- ...(deviceToken ? { deviceToken } : {}),
160
- ...(sessionResponse.deviceId ? { deviceId: sessionResponse.deviceId } : {}),
161
- ...(deviceSecret ? { deviceSecret } : {}),
151
+ deviceId: sessionResponse.deviceId,
152
+ deviceSecret,
162
153
  ...(sessionResponse.accessToken ? { accessToken: sessionResponse.accessToken } : {}),
163
154
  ...(sessionResponse.expiresAt ? { expiresAt: sessionResponse.expiresAt } : {}),
164
155
  });
@@ -309,7 +300,7 @@ export const useAuthOperations = ({
309
300
 
310
301
  if (sessionToLogout === activeSessionId && remainingAccounts.length === 0) {
311
302
  // Genuine FULL sign-out (no sessions remain): clear the persisted
312
- // refresh family so a reload's cold boot finds nothing to restore,
303
+ // device credential so a reload's cold boot finds nothing to restore,
313
304
  // then tear down local state.
314
305
  clearPersistedAuthSafe(store, logger);
315
306
  await clearSessionState();
@@ -363,9 +354,9 @@ export const useAuthOperations = ({
363
354
  // `{ all: true }`) — replaces the bearer `logoutAllSessions` +
364
355
  // web-cookie `logoutAllSessionsViaCookie` pair.
365
356
  await sessionClient.signOut({ all: true });
366
- // logoutAll is ALWAYS a full sign-out: clear the persisted refresh family
367
- // so the next cold boot finds no session to restore, then tear down local
368
- // state. The long-lived device token survives (the device is unchanged).
357
+ // logoutAll is ALWAYS a full sign-out: clear the persisted device
358
+ // credential so the next cold boot finds no session to restore, then tear
359
+ // down local state.
369
360
  clearPersistedAuthSafe(store, logger);
370
361
  await clearSessionState();
371
362
  } catch (error) {
@@ -16,12 +16,14 @@ jest.mock('socket.io-client', () => ({ __esModule: true, io: (...args: unknown[]
16
16
 
17
17
  import { createSessionClient } from '../createSessionClient';
18
18
 
19
+ // Sockets are bearer-only: `start()` opens a socket only when a bearer token is
20
+ // held. This mock reports one so the wiring test exercises the socket factory.
19
21
  function fakeOxy() {
20
22
  const listeners = new Set<(t: string | null) => void>();
21
23
  return {
22
24
  makeRequest: jest.fn().mockResolvedValue(undefined),
23
25
  getBaseURL: jest.fn().mockReturnValue('https://api.oxy.so'),
24
- getAccessToken: jest.fn().mockReturnValue(null),
26
+ getAccessToken: jest.fn().mockReturnValue('bearer.jwt.token'),
25
27
  setTokens: jest.fn(),
26
28
  onTokensChanged: jest.fn((l: (t: string | null) => void) => {
27
29
  listeners.add(l);
@@ -2,11 +2,12 @@ import type { DeviceSessionState } from '@oxyhq/contracts';
2
2
  import { logger, createMemoryAuthStateStore, refreshPersistedSession } from '@oxyhq/core';
3
3
  import { createTokenTransport } from '../tokenTransport';
4
4
 
5
- // The device-first transport mints a fallback token by rotating the persisted
6
- // refresh family via `refreshPersistedSession` (the ONE unified refresh path) —
7
- // there is no `silentSignIn`/`signInWithSharedIdentity` arm anymore. Mock that
8
- // one core function so the transport's own coalescing / logging contract can be
9
- // exercised in isolation; keep the real `logger` + memory store.
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.
10
11
  jest.mock('@oxyhq/core', () => {
11
12
  const actual = jest.requireActual('@oxyhq/core');
12
13
  return { __esModule: true, ...actual, refreshPersistedSession: jest.fn() };
@@ -1,18 +1,16 @@
1
1
  /**
2
2
  * Platform `AuthStateStore` for `@oxyhq/services`.
3
3
  *
4
- * The device-first session model persists the rotating refresh-token family
5
- * head per origin (web) / per device (native). `@oxyhq/core` owns the store
6
- * shape + logic (`createWebAuthStateStore` / `createNativeAuthStateStore`); this
7
- * module only supplies the platform storage seam and wires the native device
8
- * token to the SHARED keychain so every native Oxy app joins one DeviceSession:
4
+ * The zero-cookie device model persists the durable device credential
5
+ * (`deviceId` + `deviceSecret`) per origin (web) / per device (native), and the
6
+ * SDK re-mints the access token from that credential on cold boot. `@oxyhq/core`
7
+ * owns the store shape + logic (`createWebAuthStateStore` /
8
+ * `createNativeAuthStateStore`); this module only supplies the platform storage
9
+ * seam:
9
10
  *
10
11
  * - web → `createWebAuthStateStore()` (localStorage, in-memory fallback).
11
- * - native → `createNativeAuthStateStore(secureKV)` for the SESSION blob
12
- * (refresh token) over `expo-secure-store` (AsyncStorage fallback), with the
13
- * long-lived DEVICE token delegated to `KeyManager`'s shared keychain
14
- * (`group.so.oxy.shared`) instead of per-app storage — so an in-app login on
15
- * any native Oxy app attributes to the SAME server-side DeviceSession.
12
+ * - native → `createNativeAuthStateStore(secureKV)` for the SESSION blob over
13
+ * `expo-secure-store` (AsyncStorage fallback).
16
14
  *
17
15
  * `expo-secure-store` is loaded via a runtime-computed dynamic import (the same
18
16
  * optional-native-module pattern `OxyProvider` uses for netinfo / keyboard
@@ -20,12 +18,10 @@
20
18
  * back to AsyncStorage rather than crashing.
21
19
  */
22
20
  import {
23
- KeyManager,
24
21
  createWebAuthStateStore,
25
22
  createNativeAuthStateStore,
26
23
  type AuthStateStore,
27
24
  type NativeKeyValueStorage,
28
- logger,
29
25
  } from '@oxyhq/core';
30
26
  import { createPlatformStorage, isReactNative } from '../utils/storageHelpers';
31
27
 
@@ -112,53 +108,14 @@ function createNativeSecureKeyValueStorage(): NativeKeyValueStorage {
112
108
  /**
113
109
  * Build the platform {@link AuthStateStore} for this runtime.
114
110
  *
115
- * Native additionally routes the device token through the shared keychain so
116
- * every native Oxy app shares one server-side DeviceSession; the session blob
117
- * (refresh token) stays per-app in SecureStore. Best-effort keychain access —
118
- * a locked/failed shared-keychain read degrades to "no device token" rather
119
- * than throwing out of cold boot / refresh.
111
+ * Native persists the SESSION blob (`deviceId` + `deviceSecret` + cached access
112
+ * token) per-app in SecureStore; the SDK re-mints the access token from the
113
+ * device credential on the next cold boot.
120
114
  */
121
115
  export function createPlatformAuthStateStore(): AuthStateStore {
122
116
  if (!isReactNative()) {
123
117
  return createWebAuthStateStore();
124
118
  }
125
119
 
126
- const base = createNativeAuthStateStore(createNativeSecureKeyValueStorage());
127
- return {
128
- ...base,
129
- loadDeviceToken: async () => {
130
- try {
131
- return await KeyManager.getSharedDeviceToken();
132
- } catch (error) {
133
- logger.debug(
134
- 'Shared device-token read failed (treating as none)',
135
- { component: 'authStore', method: 'loadDeviceToken' },
136
- error,
137
- );
138
- return null;
139
- }
140
- },
141
- saveDeviceToken: async (token) => {
142
- try {
143
- await KeyManager.setSharedDeviceToken(token);
144
- } catch (error) {
145
- logger.debug(
146
- 'Shared device-token write failed (non-fatal)',
147
- { component: 'authStore', method: 'saveDeviceToken' },
148
- error,
149
- );
150
- }
151
- },
152
- clearDeviceToken: async () => {
153
- try {
154
- await KeyManager.clearSharedDeviceToken();
155
- } catch (error) {
156
- logger.debug(
157
- 'Shared device-token clear failed (non-fatal)',
158
- { component: 'authStore', method: 'clearDeviceToken' },
159
- error,
160
- );
161
- }
162
- },
163
- };
120
+ return createNativeAuthStateStore(createNativeSecureKeyValueStorage());
164
121
  }
@@ -4,7 +4,6 @@ import {
4
4
  createSessionClientHost,
5
5
  type AuthStateStore,
6
6
  type OxyServices,
7
- type SessionClientOptions,
8
7
  } from '@oxyhq/core';
9
8
  import { createTokenTransport } from './tokenTransport';
10
9
 
@@ -20,31 +19,27 @@ import { createTokenTransport } from './tokenTransport';
20
19
  * core's lazy dynamic import of a bare specifier — bundler-fragile in
21
20
  * Metro/Expo-web against the published core dist.
22
21
  * - the device-first {@link createTokenTransport}, which mints a fallback token
23
- * by rotating the persisted refresh family (`store`).
22
+ * from the persisted device credential (`store` → `deviceId` + `deviceSecret`).
24
23
  *
25
24
  * `onUnauthenticated` fires when an applied device state has zero accounts (a
26
25
  * device signout-all): the provider clears the persisted store + local state so
27
26
  * a reload does not try to restore a dead session. The host is returned
28
27
  * alongside the client so the caller can call `host.setCurrentAccountId(...)`
29
28
  * as the active account changes.
29
+ *
30
+ * The realtime socket is bearer-only: a signed-out tab cannot join any device
31
+ * room, so there is no signed-out socket wiring.
30
32
  */
31
33
  export function createSessionClient(
32
34
  oxyServices: OxyServices,
33
35
  store: AuthStateStore,
34
36
  onUnauthenticated?: () => void,
35
- /**
36
- * Optional signed-out realtime wiring: `signedOutSocketAuth` (open the socket
37
- * while signed out — web returns `true` to ride the `oxy_device` cookie,
38
- * native returns the shared device token) and `onSessionAppeared` (self-acquire
39
- * when a sibling signs in on this device).
40
- */
41
- extra?: Pick<SessionClientOptions, 'signedOutSocketAuth' | 'onSessionAppeared'>,
42
37
  ): {
43
38
  client: SessionClient;
44
39
  host: ReturnType<typeof createSessionClientHost>;
45
40
  } {
46
41
  const host = createSessionClientHost(oxyServices);
47
42
  const transport = createTokenTransport(oxyServices, store);
48
- const client = new SessionClient(host, { transport, socketFactory: io, onUnauthenticated, ...extra });
43
+ const client = new SessionClient(host, { transport, socketFactory: io, onUnauthenticated });
49
44
  return { client, host };
50
45
  }
@@ -13,10 +13,9 @@ import type { DeviceSessionState } from '@oxyhq/contracts';
13
13
  * `ensureActiveToken` is the fallback the client uses when a `session_state`
14
14
  * push arrived WITHOUT an embedded `activeToken` and the app currently holds no
15
15
  * bearer. It mints one through the ONE unified refresh path
16
- * (`refreshPersistedSession`): rotate the persisted refresh-token family
17
- * (`POST /auth/refresh-token`) and, on native, fall back to the shared-keychain
18
- * re-mint. There is no FedCM/silent-iframe arm anymore — the per-origin
19
- * persisted refresh token is the durable web credential.
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.
20
19
  *
21
20
  * Concurrent pushes coalesce onto one in-flight mint. A failure is logged and
22
21
  * swallowed: this method must never throw out (it runs inside the socket state