@oxyhq/core 8.1.0 → 9.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 (71) hide show
  1. package/README.md +1 -3
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/boot/coldBootV2.js +22 -332
  4. package/dist/cjs/crypto/keyManager.js +0 -95
  5. package/dist/cjs/index.js +6 -19
  6. package/dist/cjs/mixins/OxyServices.auth.js +4 -7
  7. package/dist/cjs/mixins/OxyServices.deviceBoot.js +19 -115
  8. package/dist/cjs/mixins/index.js +2 -3
  9. package/dist/cjs/session/SessionClient.js +29 -100
  10. package/dist/cjs/session/accountDialogController.js +0 -13
  11. package/dist/cjs/session/authStateStore.js +9 -89
  12. package/dist/cjs/session/createSessionClient.js +2 -9
  13. package/dist/cjs/session/refresh.js +46 -71
  14. package/dist/cjs/utils/registrableApex.js +2 -6
  15. package/dist/esm/.tsbuildinfo +1 -1
  16. package/dist/esm/boot/coldBootV2.js +23 -330
  17. package/dist/esm/crypto/keyManager.js +0 -95
  18. package/dist/esm/index.js +7 -11
  19. package/dist/esm/mixins/OxyServices.auth.js +4 -7
  20. package/dist/esm/mixins/OxyServices.deviceBoot.js +20 -116
  21. package/dist/esm/mixins/index.js +2 -3
  22. package/dist/esm/session/SessionClient.js +29 -100
  23. package/dist/esm/session/accountDialogController.js +0 -13
  24. package/dist/esm/session/authStateStore.js +8 -88
  25. package/dist/esm/session/createSessionClient.js +2 -9
  26. package/dist/esm/session/refresh.js +46 -71
  27. package/dist/esm/utils/registrableApex.js +2 -6
  28. package/dist/types/.tsbuildinfo +1 -1
  29. package/dist/types/HttpService.d.ts +3 -3
  30. package/dist/types/boot/coldBootV2.d.ts +28 -53
  31. package/dist/types/crypto/keyManager.d.ts +0 -21
  32. package/dist/types/index.d.ts +3 -5
  33. package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
  34. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +20 -60
  35. package/dist/types/models/interfaces.d.ts +15 -0
  36. package/dist/types/session/SessionClient.d.ts +6 -40
  37. package/dist/types/session/accountDialogController.d.ts +1 -3
  38. package/dist/types/session/authStateStore.d.ts +32 -57
  39. package/dist/types/session/createSessionClient.d.ts +2 -9
  40. package/dist/types/session/refresh.d.ts +32 -28
  41. package/dist/types/utils/registrableApex.d.ts +2 -6
  42. package/package.json +3 -3
  43. package/src/HttpService.ts +3 -3
  44. package/src/boot/__tests__/coldBootV2.test.ts +140 -353
  45. package/src/boot/coldBootV2.ts +35 -391
  46. package/src/crypto/keyManager.ts +0 -101
  47. package/src/index.ts +7 -30
  48. package/src/mixins/OxyServices.auth.ts +5 -9
  49. package/src/mixins/OxyServices.deviceBoot.ts +19 -142
  50. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +9 -96
  51. package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
  52. package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
  53. package/src/mixins/index.ts +2 -3
  54. package/src/models/interfaces.ts +15 -0
  55. package/src/session/SessionClient.ts +31 -122
  56. package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
  57. package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
  58. package/src/session/__tests__/authStateStore.test.ts +29 -42
  59. package/src/session/__tests__/refresh.test.ts +66 -52
  60. package/src/session/accountDialogController.ts +4 -18
  61. package/src/session/authStateStore.ts +32 -126
  62. package/src/session/createSessionClient.ts +2 -9
  63. package/src/session/refresh.ts +60 -92
  64. package/src/utils/registrableApex.ts +2 -6
  65. package/dist/cjs/boot/deviceBootReturn.js +0 -167
  66. package/dist/esm/boot/deviceBootReturn.js +0 -161
  67. package/dist/types/boot/deviceBootReturn.d.ts +0 -83
  68. package/src/boot/__tests__/deviceBootReturn.test.ts +0 -190
  69. package/src/boot/deviceBootReturn.ts +0 -210
  70. package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
  71. package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
package/src/index.ts CHANGED
@@ -582,18 +582,17 @@ export type {
582
582
  } from './session/accountDialogController';
583
583
 
584
584
  // ---------------------------------------------------------------------------
585
- // Device-first session machinery (auth centralization, wave 1) — additive.
586
- // Persisted auth-state store, the unified refresh handler + scheduler, the
587
- // cold-boot v2 runner, and the device-boot return-fragment consumer. Built ON
588
- // the existing `runColdBoot` primitive + `SessionClient`; the legacy
589
- // FedCM/SSO/CrossDomainAuth surface is untouched (cutover happens in F4).
585
+ // Device-first session machinery (zero-cookie transport).
586
+ // Persisted auth-state store, the unified re-mint handler + scheduler, and the
587
+ // cold-boot runner. Built ON the `runColdBoot` primitive + `SessionClient`. The
588
+ // device credential is `deviceId` + `deviceSecret`; the access token is re-minted
589
+ // via `POST /session/device/token`.
590
590
  // ---------------------------------------------------------------------------
591
591
  export {
592
592
  createWebAuthStateStore,
593
593
  createNativeAuthStateStore,
594
594
  createMemoryAuthStateStore,
595
595
  AUTH_STATE_STORAGE_KEY,
596
- DEVICE_TOKEN_STORAGE_KEY,
597
596
  } from './session/authStateStore';
598
597
  export type {
599
598
  PersistedAuthState,
@@ -610,34 +609,12 @@ export {
610
609
  } from './session/refresh';
611
610
  export type { RefreshDeps, TokenRefreshSchedulerHandle } from './session/refresh';
612
611
 
613
- export {
614
- runSessionColdBoot,
615
- createBrowserColdBootDom,
616
- isSameApex,
617
- BOOT_ATTEMPTED_KEY,
618
- } from './boot/coldBootV2';
612
+ export { runSessionColdBoot } from './boot/coldBootV2';
619
613
  export type {
620
614
  RunSessionColdBootOptions,
621
- ColdBootDom,
622
615
  SignedOutReason,
623
- } from './boot/coldBootV2';
624
-
625
- export {
626
- consumeDeviceBootReturn,
627
- parseDeviceBootFragment,
628
- hashHasBootFragment,
629
- BOOT_FRAGMENT_PARAM,
630
- BOOT_STATE_SESSION_KEY,
631
- } from './boot/deviceBootReturn';
632
- export type {
633
616
  DeviceBootSession,
634
- DeviceBootReturnOutcome,
635
- ConsumeDeviceBootReturnDeps,
636
- } from './boot/deviceBootReturn';
637
-
638
- // The web-session result contract (`WebSessionResult`) is owned by
639
- // `@oxyhq/contracts` — import it directly from there; `@oxyhq/core` does not
640
- // re-export contract types.
617
+ } from './boot/coldBootV2';
641
618
 
642
619
  // API response contracts (request/response Zod schemas + inferred types) live in
643
620
  // `@oxyhq/contracts` — the single source of truth shared by the backend and every
@@ -1128,16 +1128,15 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1128
1128
  * (`{ twoFactorRequired, loginToken }`) to complete via
1129
1129
  * {@link completeTwoFactorSignIn}, or a session arm.
1130
1130
  *
1131
- * `options.deviceToken` attributes the new session to the caller's existing
1132
- * device set (so an in-app login from a cross-apex, cookie-less web app
1133
- * still joins the same DeviceSession). On the session arm, a returned access
1134
- * token is planted immediately (mirroring {@link verifyChallenge}), so the
1135
- * caller has an authenticated client without a second round-trip.
1131
+ * On the session arm, a returned access token is planted immediately
1132
+ * (mirroring {@link verifyChallenge}), so the caller has an authenticated
1133
+ * client without a second round-trip. The response's `deviceId` +
1134
+ * `deviceSecret` are the zero-cookie restore credential the caller persists.
1136
1135
  */
1137
1136
  async passwordSignIn(
1138
1137
  identifier: string,
1139
1138
  password: string,
1140
- options: { deviceToken?: string; deviceName?: string; deviceFingerprint?: string } = {},
1139
+ options: { deviceName?: string; deviceFingerprint?: string } = {},
1141
1140
  ): Promise<LoginResult> {
1142
1141
  try {
1143
1142
  const res = await this.makeRequest<unknown>('POST', '/auth/login', {
@@ -1145,7 +1144,6 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1145
1144
  password,
1146
1145
  deviceName: options.deviceName,
1147
1146
  deviceFingerprint: options.deviceFingerprint,
1148
- deviceToken: options.deviceToken,
1149
1147
  }, { cache: false });
1150
1148
  const parsed = safeParseContract(loginResultSchema, res);
1151
1149
  if (!parsed) {
@@ -1171,7 +1169,6 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1171
1169
  loginToken: string;
1172
1170
  token?: string;
1173
1171
  backupCode?: string;
1174
- deviceToken?: string;
1175
1172
  deviceName?: string;
1176
1173
  }): Promise<LoginSessionResult> {
1177
1174
  try {
@@ -1179,7 +1176,6 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1179
1176
  loginToken: params.loginToken,
1180
1177
  token: params.token,
1181
1178
  backupCode: params.backupCode,
1182
- deviceToken: params.deviceToken,
1183
1179
  deviceName: params.deviceName,
1184
1180
  }, { cache: false });
1185
1181
  const parsed = safeParseContract(loginResultSchema, res);
@@ -1,151 +1,39 @@
1
1
  /**
2
- * Device-first bootstrap mixin (auth centralization, wave 1).
2
+ * Device-first token mint mixin.
3
3
  *
4
- * The client half of the new device-first session bootstrap: the four network
5
- * calls the cold boot (`coldBootV2`) and the unified refresh handler
6
- * (`refresh.ts`) make, plus the URL builder for the cross-apex bootstrap hop.
7
- * Every response is validated against the `@oxyhq/contracts` `deviceBoot`
8
- * schemas via `safeParseContract`, so producer (oxy-api) and consumer cannot
4
+ * The client half of the zero-cookie device transport: the single network call
5
+ * the cold boot (`coldBootV2`) and the unified re-mint handler (`refresh.ts`)
6
+ * make to turn a first-party `deviceId` + `deviceSecret` into a fresh access
7
+ * token. The response is validated against the `@oxyhq/contracts`
8
+ * `deviceTokenMintResponseSchema`, so producer (oxy-api) and consumer cannot
9
9
  * drift — an unexpected shape throws here rather than silently corrupting the
10
10
  * persisted store.
11
11
  *
12
- * These methods are ADDITIVE and carry NO persistence or token-planting side
13
- * effects of their own (except the bearer-authenticated calls that naturally
14
- * flow through `HttpService`). The cold boot / refresh handler own persistence
15
- * and `setTokens`, so the same network primitive can be reused from either
16
- * without double-planting.
12
+ * This method carries NO persistence or token-planting side effects of its own;
13
+ * the cold boot / re-mint handler own persistence and `setTokens`, so the same
14
+ * primitive can be reused from either without double-planting.
17
15
  */
18
16
  import {
19
- authTokenBundleSchema,
20
- tokenRefreshResponseSchema,
21
- deviceTokenIssueResponseSchema,
22
17
  deviceTokenMintResponseSchema,
23
- webSessionResultSchema,
24
18
  safeParseContract,
25
- type AuthTokenBundle,
26
19
  type DeviceTokenMintResponse,
27
- type TokenRefreshResponse,
28
- type WebSessionResult,
29
20
  } from '@oxyhq/contracts';
30
21
  import type { OxyServicesBase } from '../OxyServices.base';
31
22
 
32
23
  export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Base: T) {
33
24
  return class extends Base {
34
25
  /**
35
- * Exchange a single-use boot `code` (from the `#oxy_boot` return fragment)
36
- * for a token bundle. Origin-bound + GETDEL-burned server-side.
26
+ * Zero-cookie mint. Present the first-party `deviceId` + `deviceSecret` to
27
+ * `POST /session/device/token` NO bearer, NO cookies: possession of the
28
+ * secret IS the device-ownership proof. Returns a fresh short access token
29
+ * for the device's active account plus `nextDeviceSecret` (rotation-in-use)
30
+ * and the projected device-session `state`.
37
31
  *
38
- * @throws if the response does not match {@link authTokenBundleSchema}.
39
- */
40
- async exchangeBootCode(code: string): Promise<AuthTokenBundle> {
41
- try {
42
- const res = await this.makeRequest<unknown>(
43
- 'POST',
44
- '/auth/device/exchange',
45
- { code },
46
- { cache: false },
47
- );
48
- const parsed = safeParseContract(authTokenBundleSchema, res);
49
- if (!parsed) {
50
- throw new Error('device/exchange returned an unexpected response shape');
51
- }
52
- return parsed;
53
- } catch (error) {
54
- throw this.handleError(error);
55
- }
56
- }
57
-
58
- /**
59
- * Same-site fast path (`*.oxy.so` apps). Reads the first-party `oxy_device`
60
- * cookie via a credentialed same-origin fetch and either resolves a full
61
- * session bundle or reports the device is known-but-signed-out. Never
62
- * redirects.
63
- *
64
- * @throws if the response matches neither arm of {@link webSessionResultSchema}.
65
- */
66
- async requestWebSession(): Promise<WebSessionResult> {
67
- try {
68
- const res = await this.makeRequest<unknown>(
69
- 'POST',
70
- '/auth/device/web-session',
71
- undefined,
72
- { cache: false },
73
- );
74
- const parsed = safeParseContract(webSessionResultSchema, res);
75
- if (!parsed) {
76
- throw new Error('device/web-session returned an unexpected response shape');
77
- }
78
- return parsed;
79
- } catch (error) {
80
- throw this.handleError(error);
81
- }
82
- }
83
-
84
- /**
85
- * Rotate the persisted refresh-token family (web + native, one call). The
86
- * caller (refresh handler / cold boot) plants + persists the rotated pair.
87
- *
88
- * @throws if the response does not match {@link tokenRefreshResponseSchema}.
89
- */
90
- async refreshWithToken(refreshToken: string): Promise<TokenRefreshResponse> {
91
- try {
92
- const res = await this.makeRequest<unknown>(
93
- 'POST',
94
- '/auth/refresh-token',
95
- { refreshToken },
96
- // `skipAuth`: refresh-token is body-authenticated and is called from
97
- // inside the refresh handler — sending the near-expired bearer through
98
- // the preflight would deadlock. See RequestOptions.skipAuth.
99
- { cache: false, skipAuth: true },
100
- );
101
- const parsed = safeParseContract(tokenRefreshResponseSchema, res);
102
- if (!parsed) {
103
- throw new Error('auth/refresh-token returned an unexpected response shape');
104
- }
105
- return parsed;
106
- } catch (error) {
107
- throw this.handleError(error);
108
- }
109
- }
110
-
111
- /**
112
- * Issue (or rotate) the native channel's opaque device token. Bearer-gated —
113
- * the server derives the deviceId from the JWT. Native apps mirror the
114
- * returned token into the shared keychain via
115
- * `KeyManager.setSharedDeviceToken`.
116
- *
117
- * @throws if the response does not match {@link deviceTokenIssueResponseSchema}.
118
- */
119
- async issueNativeDeviceToken(): Promise<string> {
120
- try {
121
- const res = await this.makeRequest<unknown>(
122
- 'POST',
123
- '/auth/device/token',
124
- undefined,
125
- { cache: false },
126
- );
127
- const parsed = safeParseContract(deviceTokenIssueResponseSchema, res);
128
- if (!parsed) {
129
- throw new Error('auth/device/token returned an unexpected response shape');
130
- }
131
- return parsed.deviceToken;
132
- } catch (error) {
133
- throw this.handleError(error);
134
- }
135
- }
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.
32
+ * `skipAuth`: this call carries no bearer, so a 401 must surface DIRECTLY —
33
+ * never trigger `HttpService`'s 401→refresh→retry dance. The cold boot / re-
34
+ * mint handler read the 401 body (`invalid_device_secret` vs
35
+ * `no_active_session`) to decide whether to drop the secret and fall back or
36
+ * resolve signed-out.
149
37
  *
150
38
  * @throws if the response does not match {@link deviceTokenMintResponseSchema}.
151
39
  */
@@ -169,16 +57,5 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
169
57
  throw this.handleError(error);
170
58
  }
171
59
  }
172
-
173
- /**
174
- * Build the top-level `GET /auth/device/bootstrap` URL for the cross-apex
175
- * hop. The server validates `return_to` against the trusted-origin lane and
176
- * echoes `state` back in the return fragment for CSRF verification.
177
- */
178
- buildBootstrapUrl(returnTo: string, state: string): string {
179
- const base = this.getBaseURL().replace(/\/+$/, '');
180
- const params = new URLSearchParams({ return_to: returnTo, state });
181
- return `${base}/auth/device/bootstrap?${params.toString()}`;
182
- }
183
60
  };
184
61
  }
@@ -1,31 +1,11 @@
1
1
  /**
2
2
  * Device-boot mixin tests. Stubs `makeRequest` so the tests run with no network
3
- * and asserts each method's route/shape, contract validation, and the
4
- * `skipAuth` flag on the refresh call.
3
+ * and asserts `mintFromDeviceSecret`'s route/shape, contract validation, and the
4
+ * `skipAuth` flag on the bearer-less mint call.
5
5
  */
6
- import type {
7
- AuthTokenBundle,
8
- DeviceTokenMintResponse,
9
- TokenRefreshResponse,
10
- WebSessionResult,
11
- } from '@oxyhq/contracts';
6
+ import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
12
7
  import { OxyServices } from '../../OxyServices';
13
8
 
14
- const BUNDLE: AuthTokenBundle = {
15
- sessionId: 'sess-1',
16
- accessToken: 'access-1',
17
- refreshToken: 'refresh-abcdefghijkl',
18
- expiresAt: '2030-01-01T00:00:00.000Z',
19
- user: { id: 'user-1', name: {} } as AuthTokenBundle['user'],
20
- };
21
-
22
- const ROTATED: TokenRefreshResponse = {
23
- accessToken: 'access-2',
24
- refreshToken: 'refresh-mnopqrstuvwx',
25
- expiresAt: '2030-01-01T00:00:00.000Z',
26
- sessionId: 'sess-1',
27
- };
28
-
29
9
  describe('OxyServices.deviceBoot', () => {
30
10
  let oxy: OxyServices;
31
11
  let makeRequest: jest.SpyInstance;
@@ -37,70 +17,6 @@ describe('OxyServices.deviceBoot', () => {
37
17
 
38
18
  afterEach(() => jest.restoreAllMocks());
39
19
 
40
- describe('exchangeBootCode', () => {
41
- it('POSTs the code and returns the validated bundle', async () => {
42
- makeRequest.mockResolvedValueOnce(BUNDLE);
43
- const result = await oxy.exchangeBootCode('code-123');
44
- expect(result).toEqual(BUNDLE);
45
- expect(makeRequest).toHaveBeenCalledWith('POST', '/auth/device/exchange', { code: 'code-123' }, { cache: false });
46
- });
47
-
48
- it('throws on an unexpected response shape', async () => {
49
- makeRequest.mockResolvedValueOnce({ nope: true });
50
- await expect(oxy.exchangeBootCode('code-123')).rejects.toThrow();
51
- });
52
- });
53
-
54
- describe('requestWebSession', () => {
55
- it('returns the session arm (bundle nested under `session` + deviceToken)', async () => {
56
- const sessionArm: WebSessionResult = { reason: 'session', session: BUNDLE, deviceToken: 'device-abcdefghij' };
57
- makeRequest.mockResolvedValueOnce(sessionArm);
58
- const result = await oxy.requestWebSession();
59
- expect(result).toEqual(sessionArm);
60
- expect(makeRequest).toHaveBeenCalledWith('POST', '/auth/device/web-session', undefined, { cache: false });
61
- });
62
-
63
- it('returns the no-session arm', async () => {
64
- const noSession: WebSessionResult = { reason: 'no_session', deviceToken: 'device-abcdefghij' };
65
- makeRequest.mockResolvedValueOnce(noSession);
66
- const result = await oxy.requestWebSession();
67
- expect(result.reason).toBe('no_session');
68
- expect(result).toEqual(noSession);
69
- });
70
-
71
- it('throws on the OLD bare-bundle shape (regression: server now wraps in a reason envelope)', async () => {
72
- makeRequest.mockResolvedValueOnce(BUNDLE);
73
- await expect(oxy.requestWebSession()).rejects.toThrow();
74
- });
75
-
76
- it('throws when the response matches neither arm', async () => {
77
- makeRequest.mockResolvedValueOnce({ reason: 'weird' });
78
- await expect(oxy.requestWebSession()).rejects.toThrow();
79
- });
80
- });
81
-
82
- describe('refreshWithToken', () => {
83
- it('POSTs with skipAuth and returns the rotated pair', async () => {
84
- makeRequest.mockResolvedValueOnce(ROTATED);
85
- const result = await oxy.refreshWithToken('refresh-abcdefghijkl');
86
- expect(result).toEqual(ROTATED);
87
- expect(makeRequest).toHaveBeenCalledWith(
88
- 'POST',
89
- '/auth/refresh-token',
90
- { refreshToken: 'refresh-abcdefghijkl' },
91
- { cache: false, skipAuth: true },
92
- );
93
- });
94
- });
95
-
96
- describe('issueNativeDeviceToken', () => {
97
- it('POSTs and returns just the token string', async () => {
98
- makeRequest.mockResolvedValueOnce({ deviceToken: 'native-dt' });
99
- expect(await oxy.issueNativeDeviceToken()).toBe('native-dt');
100
- expect(makeRequest).toHaveBeenCalledWith('POST', '/auth/device/token', undefined, { cache: false });
101
- });
102
- });
103
-
104
20
  describe('mintFromDeviceSecret', () => {
105
21
  const MINT: DeviceTokenMintResponse = {
106
22
  accessToken: 'access-minted',
@@ -115,7 +31,7 @@ describe('OxyServices.deviceBoot', () => {
115
31
  },
116
32
  };
117
33
 
118
- it('POSTs deviceId + deviceSecret with skipAuth and returns the validated mint', async () => {
34
+ it('POSTs deviceId + deviceSecret with skipAuth (no bearer, no cache) and returns the validated mint', async () => {
119
35
  makeRequest.mockResolvedValueOnce(MINT);
120
36
  const result = await oxy.mintFromDeviceSecret('dev-1', 'ds-current-secret');
121
37
  expect(result).toEqual(MINT);
@@ -127,7 +43,7 @@ describe('OxyServices.deviceBoot', () => {
127
43
  );
128
44
  });
129
45
 
130
- it('throws on an unexpected response shape', async () => {
46
+ it('throws on an unexpected response shape (missing state / nextDeviceSecret)', async () => {
131
47
  makeRequest.mockResolvedValueOnce({ accessToken: 'a', expiresAt: 'b' });
132
48
  await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow();
133
49
  });
@@ -137,14 +53,11 @@ describe('OxyServices.deviceBoot', () => {
137
53
  makeRequest.mockRejectedValueOnce(err);
138
54
  await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow('invalid_device_secret');
139
55
  });
140
- });
141
56
 
142
- describe('buildBootstrapUrl', () => {
143
- it('builds the bootstrap URL with encoded params', () => {
144
- const url = oxy.buildBootstrapUrl('https://accounts.oxy.so/home', 'st-1');
145
- expect(url).toContain('http://test.invalid/auth/device/bootstrap?');
146
- expect(url).toContain('return_to=https%3A%2F%2Faccounts.oxy.so%2Fhome');
147
- expect(url).toContain('state=st-1');
57
+ it('propagates a 401 no_active_session so the caller can resolve signed-out', async () => {
58
+ const err = Object.assign(new Error('no_active_session'), { status: 401 });
59
+ makeRequest.mockRejectedValueOnce(err);
60
+ await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow('no_active_session');
148
61
  });
149
62
  });
150
63
  });
@@ -115,7 +115,6 @@ describe('OxyServices.onTokensChanged', () => {
115
115
  deviceId: 'dev_1',
116
116
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
117
117
  accessToken: 'access_verify',
118
- refreshToken: 'refresh_verify',
119
118
  user: { id: 'user_1', username: 'tester' },
120
119
  } as never;
121
120
  }
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Device-first password sign-in (`passwordSignIn` + `completeTwoFactorSignIn`).
3
3
  * Stubs `makeRequest` and asserts the login-result contract handling: the 2FA
4
- * arm passes through un-planted, the session arm plants its access token, the
5
- * deviceToken is threaded into the request, and a 2FA arm from verify-login is
6
- * a protocol error.
4
+ * arm passes through un-planted, the session arm plants its access token and
5
+ * carries the zero-cookie `deviceId` + `deviceSecret` restore credential,
6
+ * `deviceName` / `deviceFingerprint` are threaded into the request, and a 2FA
7
+ * arm returned from verify-login is a protocol error.
7
8
  */
8
9
  import type { LoginResult } from '@oxyhq/contracts';
9
10
  import { OxyServices } from '../../OxyServices';
@@ -13,7 +14,7 @@ const SESSION_ARM: LoginResult = {
13
14
  deviceId: 'dev-1',
14
15
  expiresAt: '2030-01-01T00:00:00.000Z',
15
16
  accessToken: 'access-1',
16
- refreshToken: 'refresh-1',
17
+ deviceSecret: 'ds-secret-1',
17
18
  user: { id: 'user-1', username: 'u' },
18
19
  };
19
20
 
@@ -39,19 +40,42 @@ describe('passwordSignIn', () => {
39
40
  expect(setTokens).not.toHaveBeenCalled();
40
41
  });
41
42
 
42
- it('plants the access token on the session arm and threads the deviceToken', async () => {
43
+ it('plants the access token on the session arm, exposes the mint credential, and threads deviceName + deviceFingerprint', async () => {
43
44
  makeRequest.mockResolvedValueOnce(SESSION_ARM);
44
- const result = await oxy.passwordSignIn('alice', 'pw', { deviceToken: 'dt-1', deviceName: 'Phone' });
45
+ const result = await oxy.passwordSignIn('alice', 'pw', { deviceName: 'Phone', deviceFingerprint: 'fp-1' });
45
46
  expect(result).toEqual(SESSION_ARM);
47
+ // The zero-cookie restore credential is on the session arm the caller persists.
48
+ expect('twoFactorRequired' in result).toBe(false);
49
+ if (!('twoFactorRequired' in result)) {
50
+ expect(result.deviceId).toBe('dev-1');
51
+ expect(result.deviceSecret).toBe('ds-secret-1');
52
+ }
46
53
  expect(setTokens).toHaveBeenCalledWith('access-1');
47
54
  expect(makeRequest).toHaveBeenCalledWith(
48
55
  'POST',
49
56
  '/auth/login',
50
- { identifier: 'alice', password: 'pw', deviceName: 'Phone', deviceFingerprint: undefined, deviceToken: 'dt-1' },
57
+ { identifier: 'alice', password: 'pw', deviceName: 'Phone', deviceFingerprint: 'fp-1' },
51
58
  { cache: false },
52
59
  );
53
60
  });
54
61
 
62
+ it('preserves the securityAlert on the session arm (contract parse must not strip it)', async () => {
63
+ const withAlert: LoginResult = {
64
+ ...SESSION_ARM,
65
+ securityAlert: {
66
+ message: 'Unusual activity detected on your account',
67
+ anomalies: [{ type: 'new_device', reason: 'first seen', details: 'Chrome / macOS' }],
68
+ },
69
+ };
70
+ makeRequest.mockResolvedValueOnce(withAlert);
71
+ const result = await oxy.passwordSignIn('alice', 'pw');
72
+ expect('twoFactorRequired' in result).toBe(false);
73
+ if (!('twoFactorRequired' in result)) {
74
+ expect(result.securityAlert?.message).toBe('Unusual activity detected on your account');
75
+ expect(result.securityAlert?.anomalies[0]?.type).toBe('new_device');
76
+ }
77
+ });
78
+
55
79
  it('throws on an unexpected response shape', async () => {
56
80
  makeRequest.mockResolvedValueOnce({ nope: true });
57
81
  await expect(oxy.passwordSignIn('alice', 'pw')).rejects.toThrow();
@@ -73,13 +97,13 @@ describe('completeTwoFactorSignIn', () => {
73
97
 
74
98
  it('verifies the login token, plants the session, and POSTs to /security/2fa/verify-login', async () => {
75
99
  makeRequest.mockResolvedValueOnce(SESSION_ARM);
76
- const result = await oxy.completeTwoFactorSignIn({ loginToken: 'lt', token: '123456', deviceToken: 'dt-1' });
100
+ const result = await oxy.completeTwoFactorSignIn({ loginToken: 'lt', token: '123456', deviceName: 'Phone' });
77
101
  expect(result).toEqual(SESSION_ARM);
78
102
  expect(setTokens).toHaveBeenCalledWith('access-1');
79
103
  expect(makeRequest).toHaveBeenCalledWith(
80
104
  'POST',
81
105
  '/security/2fa/verify-login',
82
- { loginToken: 'lt', token: '123456', backupCode: undefined, deviceToken: 'dt-1', deviceName: undefined },
106
+ { loginToken: 'lt', token: '123456', backupCode: undefined, deviceName: 'Phone' },
83
107
  { cache: false },
84
108
  );
85
109
  });
@@ -129,9 +129,8 @@ const MIXIN_PIPELINE: MixinFunction[] = [
129
129
  // so apps stop scraping link metadata locally.
130
130
  OxyServicesLinksMixin,
131
131
 
132
- // Device-first session bootstrap: the client half of the new
133
- // /auth/device/* + /auth/refresh-token surface (exchange, web-session,
134
- // refresh, native device-token, bootstrap-url builder).
132
+ // Device-first token mint: the client half of the zero-cookie transport
133
+ // (`mintFromDeviceSecret` → `POST /session/device/token`).
135
134
  OxyServicesDeviceBootMixin,
136
135
 
137
136
  // Utility (last, can use all above)
@@ -520,6 +520,16 @@ export interface ServiceAssetMetadata {
520
520
  mime: string;
521
521
  size: number;
522
522
  status: 'active' | 'trash';
523
+ /** Intrinsic width in pixels when variant/metadata extraction has run. */
524
+ width?: number;
525
+ /** Intrinsic height in pixels when variant/metadata extraction has run. */
526
+ height?: number;
527
+ /** Playback duration in seconds for video/audio assets. */
528
+ durationSec?: number;
529
+ /** Derived once from width/height at asset processing time. */
530
+ orientation?: 'portrait' | 'landscape' | 'square';
531
+ /** width / height, derived at asset processing time. */
532
+ aspectRatio?: number;
523
533
  }
524
534
 
525
535
  /**
@@ -544,6 +554,11 @@ export interface ServiceAssetMetadataBySha {
544
554
  size: number;
545
555
  status: 'active' | 'trash';
546
556
  url?: string;
557
+ width?: number;
558
+ height?: number;
559
+ durationSec?: number;
560
+ orientation?: 'portrait' | 'landscape' | 'square';
561
+ aspectRatio?: number;
547
562
  }
548
563
 
549
564
  /**