@oxyhq/core 7.1.1 → 8.1.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 (93) hide show
  1. package/README.md +48 -24
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/HttpService.js +6 -6
  4. package/dist/cjs/boot/coldBootV2.js +97 -2
  5. package/dist/cjs/boot/deviceBootReturn.js +15 -0
  6. package/dist/cjs/i18n/locales/en-US.json +44 -1
  7. package/dist/cjs/i18n/locales/es-ES.json +44 -1
  8. package/dist/cjs/i18n/locales/locales/en-US.json +45 -2
  9. package/dist/cjs/i18n/locales/locales/es-ES.json +45 -2
  10. package/dist/cjs/index.js +19 -16
  11. package/dist/cjs/mixins/OxyServices.deviceBoot.js +28 -0
  12. package/dist/cjs/server/index.js +1 -7
  13. package/dist/cjs/session/accountDialogController.js +1 -1
  14. package/dist/cjs/session/accountProjection.js +1 -1
  15. package/dist/cjs/session/authStateStore.js +6 -0
  16. package/dist/cjs/session/projectSessionState.js +1 -1
  17. package/dist/cjs/session/refresh.js +9 -0
  18. package/dist/cjs/session/sessionClientHost.js +1 -2
  19. package/dist/cjs/utils/accountUtils.js +1 -1
  20. package/dist/cjs/utils/oauthPkce.js +142 -0
  21. package/dist/cjs/utils/platform.js +1 -1
  22. package/dist/esm/.tsbuildinfo +1 -1
  23. package/dist/esm/HttpService.js +6 -6
  24. package/dist/esm/boot/coldBootV2.js +97 -2
  25. package/dist/esm/boot/deviceBootReturn.js +15 -0
  26. package/dist/esm/i18n/locales/en-US.json +44 -1
  27. package/dist/esm/i18n/locales/es-ES.json +44 -1
  28. package/dist/esm/i18n/locales/locales/en-US.json +45 -2
  29. package/dist/esm/i18n/locales/locales/es-ES.json +45 -2
  30. package/dist/esm/index.js +11 -13
  31. package/dist/esm/mixins/OxyServices.deviceBoot.js +29 -1
  32. package/dist/esm/server/index.js +0 -5
  33. package/dist/esm/session/accountDialogController.js +1 -1
  34. package/dist/esm/session/accountProjection.js +1 -1
  35. package/dist/esm/session/authStateStore.js +6 -0
  36. package/dist/esm/session/projectSessionState.js +1 -1
  37. package/dist/esm/session/refresh.js +9 -0
  38. package/dist/esm/session/sessionClientHost.js +1 -2
  39. package/dist/esm/utils/accountUtils.js +1 -1
  40. package/dist/esm/utils/oauthPkce.js +135 -0
  41. package/dist/esm/utils/platform.js +1 -1
  42. package/dist/types/.tsbuildinfo +1 -1
  43. package/dist/types/HttpService.d.ts +1 -1
  44. package/dist/types/index.d.ts +3 -2
  45. package/dist/types/mixins/OxyServices.accounts.d.ts +13 -3
  46. package/dist/types/mixins/OxyServices.connectedApps.d.ts +4 -0
  47. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +17 -1
  48. package/dist/types/mixins/OxyServices.devices.d.ts +3 -2
  49. package/dist/types/models/interfaces.d.ts +4 -4
  50. package/dist/types/server/index.d.ts +0 -1
  51. package/dist/types/session/accountDialogController.d.ts +1 -1
  52. package/dist/types/session/accountProjection.d.ts +1 -1
  53. package/dist/types/session/authStateStore.d.ts +20 -0
  54. package/dist/types/session/projectSessionState.d.ts +1 -1
  55. package/dist/types/session/refresh.d.ts +4 -8
  56. package/dist/types/session/sessionClientHost.d.ts +1 -2
  57. package/dist/types/utils/accountUtils.d.ts +1 -1
  58. package/dist/types/utils/oauthPkce.d.ts +74 -0
  59. package/dist/types/utils/platform.d.ts +1 -1
  60. package/package.json +3 -3
  61. package/src/HttpService.ts +6 -6
  62. package/src/boot/__tests__/coldBootV2.test.ts +215 -1
  63. package/src/boot/__tests__/deviceBootReturn.test.ts +32 -0
  64. package/src/boot/coldBootV2.ts +117 -2
  65. package/src/boot/deviceBootReturn.ts +15 -0
  66. package/src/i18n/locales/en-US.json +45 -2
  67. package/src/i18n/locales/es-ES.json +45 -2
  68. package/src/index.ts +23 -16
  69. package/src/mixins/OxyServices.accounts.ts +12 -0
  70. package/src/mixins/OxyServices.connectedApps.ts +4 -0
  71. package/src/mixins/OxyServices.deviceBoot.ts +38 -0
  72. package/src/mixins/OxyServices.devices.ts +6 -5
  73. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +44 -1
  74. package/src/mixins/__tests__/accounts.test.ts +1 -1
  75. package/src/models/interfaces.ts +7 -5
  76. package/src/server/index.ts +0 -6
  77. package/src/session/__tests__/authStateStore.test.ts +27 -0
  78. package/src/session/__tests__/refresh.test.ts +14 -0
  79. package/src/session/accountDialogController.ts +1 -1
  80. package/src/session/accountProjection.ts +1 -1
  81. package/src/session/authStateStore.ts +26 -0
  82. package/src/session/projectSessionState.ts +1 -1
  83. package/src/session/refresh.ts +13 -8
  84. package/src/session/sessionClientHost.ts +1 -2
  85. package/src/utils/__tests__/coldBoot.test.ts +55 -65
  86. package/src/utils/__tests__/oauthPkce.test.ts +154 -0
  87. package/src/utils/accountUtils.ts +1 -1
  88. package/src/utils/oauthPkce.ts +189 -0
  89. package/src/utils/platform.ts +1 -1
  90. package/dist/cjs/utils/ssoBounce.js +0 -24
  91. package/dist/esm/utils/ssoBounce.js +0 -21
  92. package/dist/types/utils/ssoBounce.d.ts +0 -21
  93. package/src/utils/ssoBounce.ts +0 -22
@@ -284,6 +284,10 @@ export interface Application {
284
284
  name: string;
285
285
  description?: string;
286
286
  websiteUrl?: string;
287
+ /** Public privacy-policy URL, rendered as a legal link on the OAuth consent screen. */
288
+ privacyPolicyUrl?: string;
289
+ /** Public terms-of-service URL, rendered as a legal link on the OAuth consent screen. */
290
+ termsUrl?: string;
287
291
  icon?: string;
288
292
  type: ApplicationType;
289
293
  status: ApplicationStatus;
@@ -342,6 +346,10 @@ export interface CreateApplicationInput {
342
346
  name: string;
343
347
  description?: string;
344
348
  websiteUrl?: string;
349
+ /** Public privacy-policy URL (absolute `https://`). Shown on the OAuth consent screen. */
350
+ privacyPolicyUrl?: string;
351
+ /** Public terms-of-service URL (absolute `https://`). Shown on the OAuth consent screen. */
352
+ termsUrl?: string;
345
353
  icon?: string;
346
354
  redirectUris?: string[];
347
355
  scopes?: string[];
@@ -357,6 +365,10 @@ export interface UpdateApplicationInput {
357
365
  name?: string;
358
366
  description?: string;
359
367
  websiteUrl?: string;
368
+ /** Public privacy-policy URL (absolute `https://`, or `''` to clear). Shown on the OAuth consent screen. */
369
+ privacyPolicyUrl?: string;
370
+ /** Public terms-of-service URL (absolute `https://`, or `''` to clear). Shown on the OAuth consent screen. */
371
+ termsUrl?: string;
360
372
  icon?: string;
361
373
  redirectUris?: string[];
362
374
  scopes?: string[];
@@ -41,6 +41,10 @@ export interface PublicApplication {
41
41
  icon?: string;
42
42
  /** Optional public website/homepage URL for the application. */
43
43
  websiteUrl?: string;
44
+ /** Optional public privacy-policy URL, rendered as a legal link on the consent screen. */
45
+ privacyPolicyUrl?: string;
46
+ /** Optional public terms-of-service URL, rendered as a legal link on the consent screen. */
47
+ termsUrl?: string;
44
48
  /** Application classification (set by Oxy platform staff). */
45
49
  type: ApplicationType;
46
50
  /** Whether the application is an officially endorsed Oxy application. */
@@ -19,9 +19,11 @@ import {
19
19
  authTokenBundleSchema,
20
20
  tokenRefreshResponseSchema,
21
21
  deviceTokenIssueResponseSchema,
22
+ deviceTokenMintResponseSchema,
22
23
  webSessionResultSchema,
23
24
  safeParseContract,
24
25
  type AuthTokenBundle,
26
+ type DeviceTokenMintResponse,
25
27
  type TokenRefreshResponse,
26
28
  type WebSessionResult,
27
29
  } from '@oxyhq/contracts';
@@ -132,6 +134,42 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
132
134
  }
133
135
  }
134
136
 
137
+ /**
138
+ * Zero-cookie mint (phase 2c). Present the first-party `deviceId` +
139
+ * `deviceSecret` to `POST /session/device/token` — NO bearer, NO cookies:
140
+ * possession of the secret IS the device-ownership proof. Returns a fresh
141
+ * short access token for the device's active account plus `nextDeviceSecret`
142
+ * (rotation-in-use) and the projected device-session `state`.
143
+ *
144
+ * `skipAuth` (like {@link refreshWithToken}): this call carries no bearer, so
145
+ * a 401 must surface DIRECTLY — never trigger `HttpService`'s 401→refresh→
146
+ * retry dance (which would pointlessly rotate the refresh family). The cold
147
+ * boot reads the 401 body (`invalid_device_secret` vs `no_active_session`) to
148
+ * decide whether to drop the secret and fall back or resolve signed-out.
149
+ *
150
+ * @throws if the response does not match {@link deviceTokenMintResponseSchema}.
151
+ */
152
+ async mintFromDeviceSecret(
153
+ deviceId: string,
154
+ deviceSecret: string,
155
+ ): Promise<DeviceTokenMintResponse> {
156
+ try {
157
+ const res = await this.makeRequest<unknown>(
158
+ 'POST',
159
+ '/session/device/token',
160
+ { deviceId, deviceSecret },
161
+ { cache: false, skipAuth: true },
162
+ );
163
+ const parsed = safeParseContract(deviceTokenMintResponseSchema, res);
164
+ if (!parsed) {
165
+ throw new Error('session/device/token returned an unexpected response shape');
166
+ }
167
+ return parsed;
168
+ } catch (error) {
169
+ throw this.handleError(error);
170
+ }
171
+ }
172
+
135
173
  /**
136
174
  * Build the top-level `GET /auth/device/bootstrap` URL for the cross-apex
137
175
  * hop. The server validates `return_to` against the trusted-origin lane and
@@ -2,6 +2,7 @@
2
2
  * Device Methods Mixin
3
3
  */
4
4
  import type { OxyServicesBase } from '../OxyServices.base';
5
+ import type { DeviceLinkedSession, DeviceLinkedSessionLogoutResponse } from '../models/interfaces';
5
6
 
6
7
  export function OxyServicesDevicesMixin<T extends typeof OxyServicesBase>(Base: T) {
7
8
  return class extends Base {
@@ -54,11 +55,11 @@ export function OxyServicesDevicesMixin<T extends typeof OxyServicesBase>(Base:
54
55
  * @param sessionId - The session ID
55
56
  * @returns Array of device sessions
56
57
  */
57
- async getDeviceSessions(sessionId: string): Promise<any[]> {
58
+ async getDeviceSessions(sessionId: string): Promise<DeviceLinkedSession[]> {
58
59
  try {
59
60
  // Use makeRequest for consistent error handling and optional caching
60
61
  // Cache disabled by default to ensure fresh session data
61
- return await this.makeRequest<any[]>('GET', `/session/device/sessions/${sessionId}`, undefined, {
62
+ return await this.makeRequest<DeviceLinkedSession[]>('GET', `/session/device/sessions/${sessionId}`, undefined, {
62
63
  cache: false, // Don't cache sessions - always get fresh data
63
64
  deduplicate: true, // Deduplicate concurrent requests for same sessionId
64
65
  });
@@ -74,12 +75,12 @@ export function OxyServicesDevicesMixin<T extends typeof OxyServicesBase>(Base:
74
75
  * @param excludeCurrent - Whether to exclude the current session
75
76
  * @returns Logout result
76
77
  */
77
- async logoutAllDeviceSessions(sessionId: string, deviceId?: string, excludeCurrent?: boolean): Promise<any> {
78
+ async logoutAllDeviceSessions(sessionId: string, deviceId?: string, excludeCurrent?: boolean): Promise<DeviceLinkedSessionLogoutResponse> {
78
79
  try {
79
- const urlParams: any = {};
80
+ const urlParams: Record<string, string> = {};
80
81
  if (deviceId) urlParams.deviceId = deviceId;
81
82
  if (excludeCurrent) urlParams.excludeCurrent = 'true';
82
- return await this.makeRequest('POST', `/session/device/logout-all/${sessionId}`, urlParams, { cache: false });
83
+ return await this.makeRequest<DeviceLinkedSessionLogoutResponse>('POST', `/session/device/logout-all/${sessionId}`, urlParams, { cache: false });
83
84
  } catch (error) {
84
85
  throw this.handleError(error);
85
86
  }
@@ -3,7 +3,12 @@
3
3
  * and asserts each method's route/shape, contract validation, and the
4
4
  * `skipAuth` flag on the refresh call.
5
5
  */
6
- import type { AuthTokenBundle, TokenRefreshResponse, WebSessionResult } from '@oxyhq/contracts';
6
+ import type {
7
+ AuthTokenBundle,
8
+ DeviceTokenMintResponse,
9
+ TokenRefreshResponse,
10
+ WebSessionResult,
11
+ } from '@oxyhq/contracts';
7
12
  import { OxyServices } from '../../OxyServices';
8
13
 
9
14
  const BUNDLE: AuthTokenBundle = {
@@ -96,6 +101,44 @@ describe('OxyServices.deviceBoot', () => {
96
101
  });
97
102
  });
98
103
 
104
+ describe('mintFromDeviceSecret', () => {
105
+ const MINT: DeviceTokenMintResponse = {
106
+ accessToken: 'access-minted',
107
+ expiresAt: '2030-01-01T00:00:00.000Z',
108
+ nextDeviceSecret: 'ds-next-secret',
109
+ state: {
110
+ deviceId: 'dev-1',
111
+ accounts: [{ accountId: 'user-1', sessionId: 'sess-1', authuser: 0 }],
112
+ activeAccountId: 'user-1',
113
+ revision: 3,
114
+ updatedAt: 1_700_000_000_000,
115
+ },
116
+ };
117
+
118
+ it('POSTs deviceId + deviceSecret with skipAuth and returns the validated mint', async () => {
119
+ makeRequest.mockResolvedValueOnce(MINT);
120
+ const result = await oxy.mintFromDeviceSecret('dev-1', 'ds-current-secret');
121
+ expect(result).toEqual(MINT);
122
+ expect(makeRequest).toHaveBeenCalledWith(
123
+ 'POST',
124
+ '/session/device/token',
125
+ { deviceId: 'dev-1', deviceSecret: 'ds-current-secret' },
126
+ { cache: false, skipAuth: true },
127
+ );
128
+ });
129
+
130
+ it('throws on an unexpected response shape', async () => {
131
+ makeRequest.mockResolvedValueOnce({ accessToken: 'a', expiresAt: 'b' });
132
+ await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow();
133
+ });
134
+
135
+ it('propagates a rejected request (e.g. 401 invalid_device_secret)', async () => {
136
+ const err = Object.assign(new Error('invalid_device_secret'), { status: 401 });
137
+ makeRequest.mockRejectedValueOnce(err);
138
+ await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow('invalid_device_secret');
139
+ });
140
+ });
141
+
99
142
  describe('buildBootstrapUrl', () => {
100
143
  it('builds the bootstrap URL with encoded params', () => {
101
144
  const url = oxy.buildBootstrapUrl('https://accounts.oxy.so/home', 'st-1');
@@ -83,7 +83,7 @@ const appFixture: Application = {
83
83
  isOfficial: true,
84
84
  isInternal: false,
85
85
  capabilities: [],
86
- redirectUris: ['https://mention.earth/__oxy/sso-callback'],
86
+ redirectUris: ['https://mention.earth/oauth/callback'],
87
87
  scopes: ['profile'],
88
88
  createdByUserId: 'u1',
89
89
  ownerAccountId: 'acc1',
@@ -645,8 +645,10 @@ export interface AssetUploadProgress {
645
645
  error?: string;
646
646
  }
647
647
 
648
- // Device Session interfaces
649
- export interface DeviceSession {
648
+ // Device-linked session interfaces — the sessions that share one physical
649
+ // device (GET /session/device/sessions/:sessionId). Distinct from the
650
+ // server-authority `DeviceSession` Mongoose model / `DeviceSessionState`.
651
+ export interface DeviceLinkedSession {
650
652
  sessionId: string;
651
653
  deviceId: string;
652
654
  deviceName: string;
@@ -658,12 +660,12 @@ export interface DeviceSession {
658
660
  createdAt?: string;
659
661
  }
660
662
 
661
- export interface DeviceSessionsResponse {
663
+ export interface DeviceLinkedSessionsResponse {
662
664
  deviceId: string;
663
- sessions: DeviceSession[];
665
+ sessions: DeviceLinkedSession[];
664
666
  }
665
667
 
666
- export interface DeviceSessionLogoutResponse {
668
+ export interface DeviceLinkedSessionLogoutResponse {
667
669
  message: string;
668
670
  deviceId: string;
669
671
  sessionsTerminated: number;
@@ -69,9 +69,3 @@ export { verifySecret } from './verifySecret';
69
69
  // Pure host handling (no browser deps), so it is safe on the server subpath and
70
70
  // lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
71
71
  export { registrableApex } from '../utils/registrableApex';
72
-
73
- // The single RP callback path the IdP redirects back to. A pure wire-contract
74
- // constant (no browser deps at module top level), re-used server-side so the
75
- // `/sso/establish-token` `return_to` cannot drift from what `/sso/establish`
76
- // validates.
77
- export { SSO_CALLBACK_PATH } from '../utils/ssoBounce';
@@ -65,6 +65,33 @@ describe('createWebAuthStateStore', () => {
65
65
  expect(await store.load()).toEqual(SAMPLE);
66
66
  });
67
67
 
68
+ it('round-trips the optional phase-2c deviceId + deviceSecret', async () => {
69
+ installLocalStorage(makeFakeStorage());
70
+ const store = createWebAuthStateStore();
71
+ const withCreds: PersistedAuthState = { ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' };
72
+
73
+ await store.save(withCreds);
74
+ const loaded = await store.load();
75
+ expect(loaded?.deviceId).toBe('dev-abc');
76
+ expect(loaded?.deviceSecret).toBe('ds-secret-xyz');
77
+ expect(loaded).toEqual(withCreds);
78
+ });
79
+
80
+ it('deserializes a legacy blob with no device credentials (additive — fields absent)', async () => {
81
+ const storage = makeFakeStorage();
82
+ installLocalStorage(storage);
83
+ const store = createWebAuthStateStore();
84
+
85
+ storage.setItem(
86
+ AUTH_STATE_STORAGE_KEY,
87
+ JSON.stringify({ sessionId: 's-1', refreshToken: 'r-abcdefghijklmnop', userId: 'u-1' }),
88
+ );
89
+ const loaded = await store.load();
90
+ expect(loaded).not.toBeNull();
91
+ expect(loaded && 'deviceId' in loaded).toBe(false);
92
+ expect(loaded && 'deviceSecret' in loaded).toBe(false);
93
+ });
94
+
68
95
  it('clear() wipes the session but the deviceToken survives', async () => {
69
96
  installLocalStorage(makeFakeStorage());
70
97
  const store = createWebAuthStateStore();
@@ -53,6 +53,20 @@ describe('refreshPersistedSession — arm 1 (refresh-token rotation)', () => {
53
53
  });
54
54
  });
55
55
 
56
+ it('carries the persisted deviceId + deviceSecret (phase 2c) forward across a rotation', async () => {
57
+ const store = createMemoryAuthStateStore();
58
+ await store.save({ ...STORED, deviceId: 'dev-mint', deviceSecret: 'ds-secret-orig' });
59
+ const { oxy } = makeOxy();
60
+
61
+ await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
62
+
63
+ const persisted = await store.load();
64
+ expect(persisted?.deviceId).toBe('dev-mint');
65
+ expect(persisted?.deviceSecret).toBe('ds-secret-orig');
66
+ // The refresh family head still rotated.
67
+ expect(persisted?.refreshToken).toBe('refresh-new-abcdefghij');
68
+ });
69
+
56
70
  it('clears the store on a family-revoked (401) error', async () => {
57
71
  const store = createMemoryAuthStateStore();
58
72
  await store.save(STORED);
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A framework-agnostic state machine + subscribe/getSnapshot store (the same
5
5
  * pattern {@link SessionClient} uses — no React, no RN) that both
6
- * `@oxyhq/services` (RN `OxyProvider`) and `@oxyhq/auth` (web `WebOxyProvider`)
6
+ * every `OxyProvider` platform variant (Expo/RN and RN-Web)
7
7
  * bind to via `useSyncExternalStore`, so the account chooser is ONE
8
8
  * implementation across the ecosystem instead of the five drifting copies it
9
9
  * replaces.
@@ -5,7 +5,7 @@
5
5
  * merging the device's server-authoritative session set (`DeviceSessionState`
6
6
  * from {@link SessionClient}) with the caller's account graph (`AccountNode[]`
7
7
  * from `oxyServices.listAccounts()`), deduped by `accountId`. This lives in
8
- * `@oxyhq/core` so `@oxyhq/services` (RN) and `@oxyhq/auth` (web) — and
8
+ * `@oxyhq/core` so every `@oxyhq/services` platform variant — and
9
9
  * `auth.oxy.so` — all render the SAME list from the SAME logic and cannot
10
10
  * diverge.
11
11
  *
@@ -39,6 +39,26 @@ export interface PersistedAuthState {
39
39
  refreshToken: string;
40
40
  userId: string;
41
41
  deviceToken?: string;
42
+ /**
43
+ * The stable device identifier this session is bound to (phase 2c —
44
+ * zero-cookie transport). Persisted alongside {@link deviceSecret} because the
45
+ * `POST /session/device/token` mint presents BOTH — the secret is the proof,
46
+ * the deviceId selects the device doc. Sourced from the lanes that carry it
47
+ * (password login / 2FA / QR claim / challenge verify); the cookie-bootstrap
48
+ * lanes (`AuthTokenBundle`) omit it and preserve any prior value. Additive: a
49
+ * blob without it simply never takes the mint lane and falls back to the
50
+ * refresh family.
51
+ */
52
+ deviceId?: string;
53
+ /**
54
+ * The rotating device secret (phase 2c — zero-cookie transport). Possession of
55
+ * it mints a short access token for the device's active account via
56
+ * `POST /session/device/token`, replacing the cookie lane. Rotated in-use: the
57
+ * mint returns `nextDeviceSecret`, which the cold boot persists BEFORE planting
58
+ * the minted access token (multi-tab anti-loss). Additive and optional — same
59
+ * XSS risk profile as the already-persisted `refreshToken`.
60
+ */
61
+ deviceSecret?: string;
42
62
  /** Optional warm-boot access token (short-lived; see interface docs). */
43
63
  accessToken?: string;
44
64
  /** Optional warm-boot access-token expiry, ISO-8601. */
@@ -130,6 +150,12 @@ function deserialize(raw: string | null): PersistedAuthState | null {
130
150
  if (typeof candidate.deviceToken === 'string' && candidate.deviceToken.length > 0) {
131
151
  state.deviceToken = candidate.deviceToken;
132
152
  }
153
+ if (typeof candidate.deviceId === 'string' && candidate.deviceId.length > 0) {
154
+ state.deviceId = candidate.deviceId;
155
+ }
156
+ if (typeof candidate.deviceSecret === 'string' && candidate.deviceSecret.length > 0) {
157
+ state.deviceSecret = candidate.deviceSecret;
158
+ }
133
159
  if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
134
160
  state.accessToken = candidate.accessToken;
135
161
  }
@@ -5,7 +5,7 @@ import type { User } from '../models/interfaces';
5
5
  /**
6
6
  * Pure projection helpers: `DeviceSessionState` (the device-scoped
7
7
  * multi-account session-sync state produced by `SessionClient`) -> the
8
- * shapes consumers (`@oxyhq/services`, `@oxyhq/auth`) render today
8
+ * shapes `@oxyhq/services` consumers render today
9
9
  * (`ClientSession[]`, an active session id, an active `User`).
10
10
  *
11
11
  * No I/O. The caller fetches profiles via
@@ -1,11 +1,8 @@
1
1
  /**
2
2
  * Unified token refresh — THE single refresh implementation for web + native.
3
3
  *
4
- * Before device-first, refresh was duplicated: `@oxyhq/auth`'s
5
- * `session/tokenRefresh.ts` (per-apex `/auth/silent` iframe) and
6
- * `@oxyhq/services`'s `inSessionTokenRefresh.ts` (native shared-key). This
7
- * module replaces both with ONE persisted-refresh-token rotation shared by
8
- * every consumer:
4
+ * ONE persisted-refresh-token rotation shared by every consumer (it replaced
5
+ * the pre-device-first per-platform duplicates):
9
6
  *
10
7
  * - `refreshPersistedSession` — arm 1 rotates the stored refresh-token family
11
8
  * (`POST /auth/refresh-token`), planting + persisting the rotated pair; arm 2
@@ -16,9 +13,8 @@
16
13
  * - `createAuthRefreshHandler` / `installAuthRefreshHandler` wire arm 1+2 into
17
14
  * `HttpService.setAuthRefreshHandler`, keeping that layer's single-flight
18
15
  * dedup + cooldown (this module does NOT reimplement them).
19
- * - `startTokenRefreshScheduler` — a proactive scheduler (lifted from the
20
- * better of the two prior duplicates, `@oxyhq/auth`'s `tokenRefresh.ts`),
21
- * decoupled from any React / auth-sdk type: refreshes ~60s before `exp`,
16
+ * - `startTokenRefreshScheduler` — a proactive scheduler decoupled from any
17
+ * React type: refreshes ~60s before `exp`,
22
18
  * re-arms on token change + web tab-focus, `.unref?.()`s its timer in Node.
23
19
  *
24
20
  * Framework-free; no module-level mutable state.
@@ -138,6 +134,15 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
138
134
  if (persisted.deviceToken) {
139
135
  next.deviceToken = persisted.deviceToken;
140
136
  }
137
+ // The refresh response carries no device credentials — carry the persisted
138
+ // deviceId/deviceSecret (phase 2c) forward so a rotation never drops the
139
+ // zero-cookie mint lane (mirrors the deviceToken preservation above).
140
+ if (persisted.deviceId) {
141
+ next.deviceId = persisted.deviceId;
142
+ }
143
+ if (persisted.deviceSecret) {
144
+ next.deviceSecret = persisted.deviceSecret;
145
+ }
141
146
  await store.save(next);
142
147
  return rotated.accessToken;
143
148
  } catch (error) {
@@ -7,8 +7,7 @@ import type { SessionClientHost } from './SessionClient';
7
7
  * `SessionClient` is host-agnostic: it only needs a REST + token surface.
8
8
  * `OxyServices` already exposes all of that except `getCurrentAccountId`,
9
9
  * which has no direct equivalent — the adapter holds a mutable ref set by
10
- * the caller (`OxyContext` in `@oxyhq/services`, `WebOxyProvider` in
11
- * `@oxyhq/auth`) via `setCurrentAccountId`.
10
+ * the caller (`OxyContext` in `@oxyhq/services`) via `setCurrentAccountId`.
12
11
  *
13
12
  * Shared here (rather than duplicated per consumer) because it is entirely
14
13
  * platform-agnostic: every method it calls exists identically on