@oxyhq/core 8.0.0 → 9.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 (68) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/coldBootV2.js +66 -281
  3. package/dist/cjs/crypto/keyManager.js +0 -95
  4. package/dist/cjs/index.js +6 -19
  5. package/dist/cjs/mixins/OxyServices.auth.js +4 -7
  6. package/dist/cjs/mixins/OxyServices.deviceBoot.js +24 -92
  7. package/dist/cjs/mixins/index.js +2 -3
  8. package/dist/cjs/session/SessionClient.js +29 -100
  9. package/dist/cjs/session/accountDialogController.js +0 -13
  10. package/dist/cjs/session/authStateStore.js +14 -88
  11. package/dist/cjs/session/createSessionClient.js +2 -9
  12. package/dist/cjs/session/refresh.js +46 -62
  13. package/dist/cjs/utils/registrableApex.js +2 -6
  14. package/dist/esm/.tsbuildinfo +1 -1
  15. package/dist/esm/boot/coldBootV2.js +67 -279
  16. package/dist/esm/crypto/keyManager.js +0 -95
  17. package/dist/esm/index.js +7 -11
  18. package/dist/esm/mixins/OxyServices.auth.js +4 -7
  19. package/dist/esm/mixins/OxyServices.deviceBoot.js +25 -93
  20. package/dist/esm/mixins/index.js +2 -3
  21. package/dist/esm/session/SessionClient.js +29 -100
  22. package/dist/esm/session/accountDialogController.js +0 -13
  23. package/dist/esm/session/authStateStore.js +13 -87
  24. package/dist/esm/session/createSessionClient.js +2 -9
  25. package/dist/esm/session/refresh.js +46 -62
  26. package/dist/esm/utils/registrableApex.js +2 -6
  27. package/dist/types/.tsbuildinfo +1 -1
  28. package/dist/types/HttpService.d.ts +3 -3
  29. package/dist/types/boot/coldBootV2.d.ts +28 -53
  30. package/dist/types/crypto/keyManager.d.ts +0 -21
  31. package/dist/types/index.d.ts +3 -5
  32. package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
  33. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +22 -46
  34. package/dist/types/session/SessionClient.d.ts +4 -38
  35. package/dist/types/session/accountDialogController.d.ts +1 -3
  36. package/dist/types/session/authStateStore.d.ts +38 -43
  37. package/dist/types/session/createSessionClient.d.ts +2 -9
  38. package/dist/types/session/refresh.d.ts +32 -28
  39. package/dist/types/utils/registrableApex.d.ts +2 -6
  40. package/package.json +2 -2
  41. package/src/HttpService.ts +3 -3
  42. package/src/boot/__tests__/coldBootV2.test.ts +237 -236
  43. package/src/boot/coldBootV2.ts +92 -333
  44. package/src/crypto/keyManager.ts +0 -101
  45. package/src/index.ts +7 -30
  46. package/src/mixins/OxyServices.auth.ts +5 -9
  47. package/src/mixins/OxyServices.deviceBoot.ts +30 -115
  48. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +36 -80
  49. package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
  50. package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
  51. package/src/mixins/index.ts +2 -3
  52. package/src/session/SessionClient.ts +29 -120
  53. package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
  54. package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
  55. package/src/session/__tests__/authStateStore.test.ts +47 -33
  56. package/src/session/__tests__/refresh.test.ts +72 -44
  57. package/src/session/accountDialogController.ts +4 -18
  58. package/src/session/authStateStore.ts +43 -111
  59. package/src/session/createSessionClient.ts +2 -9
  60. package/src/session/refresh.ts +60 -83
  61. package/src/utils/registrableApex.ts +2 -6
  62. package/dist/cjs/boot/deviceBootReturn.js +0 -152
  63. package/dist/esm/boot/deviceBootReturn.js +0 -146
  64. package/dist/types/boot/deviceBootReturn.d.ts +0 -83
  65. package/src/boot/__tests__/deviceBootReturn.test.ts +0 -158
  66. package/src/boot/deviceBootReturn.ts +0 -195
  67. package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
  68. package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
@@ -4,12 +4,8 @@
4
4
  * The client FAPI auto-detection helper was removed in the device-first cutover
5
5
  * (which is why this file is now named for what it actually is, not the old
6
6
  * `fapiAutoDetect`). What survives is the pure registrable-domain kernel, still
7
- * used server-side by the api's device-first same-apex trust checks
8
- * (`deviceAuth.ts`'s `POST /auth/device/web-session`, via `sameSite.ts`'s
9
- * `isSameSiteTrustedRequest`) and the `@oxyhq/core/server` CORS/re-export layer.
10
- *
11
- * `registrableApex` is NOT legacy — the device-first same-apex check is a live
12
- * consumer, so this kernel stays regardless of the SSO/FedCM removal.
7
+ * used by the `@oxyhq/core/server` CORS layer (the `*.oxy.so` same-apex trust
8
+ * check) and its re-export surface.
13
9
  */
14
10
  /**
15
11
  * Compute the bare registrable apex (eTLD+1) of a hostname using the Public
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "8.0.0",
3
+ "version": "9.0.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -94,7 +94,7 @@
94
94
  }
95
95
  },
96
96
  "dependencies": {
97
- "@oxyhq/contracts": "^0.11.0",
97
+ "@oxyhq/contracts": "^0.13.0",
98
98
  "@oxyhq/protocol": "^0.1.2",
99
99
  "bip39": "^3.1.0",
100
100
  "buffer": "^6.0.3",
@@ -74,13 +74,13 @@ export interface RequestOptions {
74
74
  * Skip BOTH the bearer auth header (and its near-expiry preflight refresh)
75
75
  * AND the 401-driven auto-refresh/retry for this request.
76
76
  *
77
- * Required for the body-authenticated refresh endpoint (`POST
78
- * /auth/refresh-token`): it does not need a bearer, and — critically — it is
77
+ * Required for the body-authenticated device-secret mint (`POST
78
+ * /session/device/token`): it does not need a bearer, and — critically — it is
79
79
  * itself invoked from inside the registered `AuthRefreshHandler`. If it went
80
80
  * through the normal preflight, `getAuthHeader` would call
81
81
  * `refreshAccessToken` while the handler-owning `tokenRefreshPromise` is
82
82
  * still in flight and await ITSELF (deadlock). Skipping auth makes the
83
- * refresh call fully independent of the current (near-expired) bearer.
83
+ * mint call fully independent of the current (near-expired) bearer.
84
84
  */
85
85
  skipAuth?: boolean;
86
86
  }
@@ -1,261 +1,215 @@
1
+ /**
2
+ * Cold boot v3 (`runSessionColdBoot`) — the zero-cookie, DOM-less device-first
3
+ * boot. Only two ordered steps remain:
4
+ * 1. `device-secret-mint` (web + native) — mint an access token from the
5
+ * persisted `deviceId` + `deviceSecret`.
6
+ * 2. `shared-key-signin` (native) — re-mint from the shared-keychain identity.
7
+ * Anything unresolved ends signed out (never a redirect).
8
+ */
1
9
  import type { OxyServices } from '../../OxyServices';
2
- import type { AuthTokenBundle, TokenRefreshResponse, WebSessionResult } from '@oxyhq/contracts';
10
+ import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
3
11
  import type { SessionLoginResponse } from '../../models/session';
4
- import {
5
- runSessionColdBoot,
6
- createBrowserColdBootDom,
7
- isSameApex,
8
- BOOT_ATTEMPTED_KEY,
9
- type ColdBootDom,
10
- } from '../coldBootV2';
11
- import { BOOT_STATE_SESSION_KEY } from '../deviceBootReturn';
12
- import { createMemoryAuthStateStore, type AuthStateStore } from '../../session/authStateStore';
13
- import { KeyManager } from '../../crypto/keyManager';
14
-
15
- const BUNDLE: AuthTokenBundle = {
16
- sessionId: 'sess-boot',
17
- accessToken: 'access-boot',
18
- refreshToken: 'refresh-boot-abcdefghij',
19
- expiresAt: '2030-01-01T00:00:00.000Z',
20
- user: { id: 'user-boot', name: {} } as AuthTokenBundle['user'],
21
- };
22
-
23
- const ROTATED: TokenRefreshResponse = {
24
- accessToken: 'access-rot',
25
- refreshToken: 'refresh-rot-abcdefghij',
26
- expiresAt: '2030-01-01T00:00:00.000Z',
27
- sessionId: 'sess-rot',
28
- };
12
+ import { runSessionColdBoot } from '../coldBootV2';
13
+ import { createMemoryAuthStateStore, type PersistedAuthState } from '../../session/authStateStore';
29
14
 
30
15
  interface OxyOverrides {
31
- baseURL?: string;
32
- exchangeBootCode?: OxyServices['exchangeBootCode'];
33
- refreshWithToken?: OxyServices['refreshWithToken'];
34
16
  signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
35
- issueNativeDeviceToken?: OxyServices['issueNativeDeviceToken'];
36
- requestWebSession?: OxyServices['requestWebSession'];
17
+ mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
37
18
  }
38
19
 
39
20
  function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
40
21
  const setTokens = jest.fn();
41
22
  const oxy = {
42
- getBaseURL: () => overrides.baseURL ?? 'https://api.oxy.so',
23
+ getBaseURL: () => 'https://api.oxy.so',
43
24
  setTokens,
44
- exchangeBootCode: overrides.exchangeBootCode ?? (async () => BUNDLE),
45
- refreshWithToken: overrides.refreshWithToken ?? (async () => ROTATED),
46
25
  signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
47
- issueNativeDeviceToken: overrides.issueNativeDeviceToken ?? (async () => 'native-device-token'),
48
- requestWebSession:
49
- overrides.requestWebSession
50
- ?? (async () => ({ reason: 'no_session', deviceToken: 'dt-web' }) as WebSessionResult),
51
- buildBootstrapUrl: (returnTo: string, state: string) =>
52
- `https://api.oxy.so/auth/device/bootstrap?return_to=${encodeURIComponent(returnTo)}&state=${state}`,
26
+ // Default: no persisted secret in these fixtures, so the mint step skips
27
+ // before ever calling this. Tests that exercise the mint pass an override.
28
+ mintFromDeviceSecret:
29
+ overrides.mintFromDeviceSecret
30
+ ?? (async () => {
31
+ throw new Error('mintFromDeviceSecret not stubbed');
32
+ }),
53
33
  } as unknown as OxyServices;
54
34
  return { oxy, setTokens };
55
35
  }
56
36
 
57
- interface DomHandle {
58
- dom: ColdBootDom;
59
- navigate: jest.Mock;
60
- strip: jest.Mock;
61
- session: Map<string, string>;
62
- local: Map<string, string>;
63
- }
37
+ const MINT: DeviceTokenMintResponse = {
38
+ accessToken: 'access-minted',
39
+ expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
40
+ nextDeviceSecret: 'ds-next-secret',
41
+ state: {
42
+ deviceId: 'dev-mint',
43
+ accounts: [{ accountId: 'user-mint', sessionId: 'sess-mint', authuser: 0 }],
44
+ activeAccountId: 'user-mint',
45
+ revision: 2,
46
+ updatedAt: 1_700_000_000_000,
47
+ },
48
+ };
64
49
 
65
- function makeDom(opts: { hash?: string; hostname?: string; sessionState?: string; attempted?: boolean } = {}): DomHandle {
66
- const session = new Map<string, string>();
67
- const local = new Map<string, string>();
68
- if (opts.sessionState !== undefined) session.set(BOOT_STATE_SESSION_KEY, opts.sessionState);
69
- if (opts.attempted) local.set(BOOT_ATTEMPTED_KEY, '1');
70
- const navigate = jest.fn();
71
- const strip = jest.fn();
72
- const dom: ColdBootDom = {
73
- getHash: () => opts.hash ?? '',
74
- stripFragment: strip,
75
- getSessionItem: (k) => session.get(k) ?? null,
76
- setSessionItem: (k, v) => {
77
- session.set(k, v);
78
- },
79
- removeSessionItem: (k) => {
80
- session.delete(k);
81
- },
82
- getLocalItem: (k) => local.get(k) ?? null,
83
- setLocalItem: (k, v) => {
84
- local.set(k, v);
85
- },
86
- getLocationHostname: () => opts.hostname ?? 'accounts.oxy.so',
87
- getReturnToHref: () => 'https://accounts.oxy.so/home',
88
- navigate,
89
- randomState: () => 'state-fixed-1234',
90
- };
91
- return { dom, navigate, strip, session, local };
50
+ /** A 401 error shaped like `HttpService`/`handleError` output for the given body. */
51
+ function mint401(body: string): Error & { status: number } {
52
+ return Object.assign(new Error(body), { status: 401 });
92
53
  }
93
54
 
94
55
  const WEB = { isWeb: true, isNative: false };
95
56
  const NATIVE = { isWeb: false, isNative: true };
96
57
 
97
- describe('isSameApex', () => {
98
- it('groups by registrable domain', () => {
99
- expect(isSameApex('accounts.oxy.so', 'api.oxy.so')).toBe(true);
100
- expect(isSameApex('oxy.so', 'api.oxy.so')).toBe(true);
101
- expect(isSameApex('app.mention.earth', 'api.oxy.so')).toBe(false);
102
- expect(isSameApex('homiio.com', 'api.oxy.so')).toBe(false);
103
- });
58
+ /** Seed a store with the zero-cookie mint credential. */
59
+ function seedCredStore(extra: Partial<PersistedAuthState> = {}) {
60
+ const store = createMemoryAuthStateStore();
61
+ return {
62
+ store,
63
+ seed: async () => {
64
+ await store.save({
65
+ sessionId: 'sess-old',
66
+ userId: 'user-old',
67
+ deviceId: 'dev-mint',
68
+ deviceSecret: 'ds-secret-orig',
69
+ ...extra,
70
+ });
71
+ },
72
+ };
73
+ }
104
74
 
105
- it('does NOT collapse distinct IPv4 hosts that share trailing octets', () => {
106
- // The last-2-labels heuristic would map both to "1.1" must be exact.
107
- expect(isSameApex('192.168.1.1', '10.0.1.1')).toBe(false);
108
- expect(isSameApex('192.168.1.10', '192.168.1.5')).toBe(false);
109
- expect(isSameApex('192.168.1.5', '192.168.1.5')).toBe(true);
110
- });
75
+ describe('runSessionColdBoot device-secret-mint', () => {
76
+ it('wins FIRST via the mint, persisting nextDeviceSecret BEFORE planting the token', async () => {
77
+ const { store, seed } = seedCredStore();
78
+ await seed();
79
+ const mintFromDeviceSecret = jest.fn(async () => MINT);
80
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
81
+ const saveSpy = jest.spyOn(store, 'save');
82
+ const onSession = jest.fn();
111
83
 
112
- it('requires exact equality for single-label / IPv6 / localhost hosts', () => {
113
- expect(isSameApex('localhost', 'localhost')).toBe(true);
114
- expect(isSameApex('localhost', 'api.oxy.so')).toBe(false);
115
- expect(isSameApex('::1', '::1')).toBe(true);
116
- expect(isSameApex('fe80::1', 'fe80::2')).toBe(false);
84
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSession });
85
+
86
+ expect(outcome).toEqual({ kind: 'session', via: 'device-secret-mint', session: expect.any(Object) });
87
+ expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
88
+ expect(setTokens).toHaveBeenCalledWith('access-minted');
89
+ // Session identity comes from the mint's authoritative active account.
90
+ expect(onSession).toHaveBeenCalledWith(
91
+ expect.objectContaining({ sessionId: 'sess-mint', userId: 'user-mint', via: 'device-secret-mint' }),
92
+ );
93
+ // Rotation-in-use anti-loss: the store held the NEXT secret before the plant.
94
+ const lastSaveOrder = Math.max(...saveSpy.mock.invocationCallOrder);
95
+ const firstPlantOrder = Math.min(...setTokens.mock.invocationCallOrder);
96
+ expect(lastSaveOrder).toBeLessThan(firstPlantOrder);
97
+ expect(await store.load()).toMatchObject({
98
+ deviceId: 'dev-mint',
99
+ deviceSecret: 'ds-next-secret',
100
+ sessionId: 'sess-mint',
101
+ userId: 'user-mint',
102
+ accessToken: 'access-minted',
103
+ });
117
104
  });
118
- });
119
105
 
120
- describe('runSessionColdBoot step ordering', () => {
121
- it('stored-tokens warm-plants a still-valid access token before any hop', async () => {
122
- const store = createMemoryAuthStateStore();
123
- await store.save({
124
- sessionId: 'sess-warm',
125
- refreshToken: 'r-abcdefghij',
126
- userId: 'user-warm',
127
- accessToken: 'warm-token',
128
- expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
129
- });
130
- const { oxy, setTokens } = makeOxy();
131
- const requestWebSession = jest.spyOn(oxy, 'requestWebSession');
132
- const onSession = jest.fn();
106
+ it('mints on native too (the step runs on both platforms)', async () => {
107
+ const { store, seed } = seedCredStore();
108
+ await seed();
109
+ const mintFromDeviceSecret = jest.fn(async () => MINT);
110
+ const signInWithSharedIdentity = jest.fn(async () => null);
111
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
133
112
 
134
- const outcome = await runSessionColdBoot({
135
- oxy, store, platform: WEB, dom: makeDom().dom, onSession,
136
- });
113
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
137
114
 
138
- expect(outcome).toEqual({ kind: 'session', via: 'stored-tokens', session: expect.any(Object) });
139
- expect(setTokens).toHaveBeenCalledWith('warm-token');
140
- expect(onSession).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-warm', via: 'stored-tokens' }));
141
- expect(requestWebSession).not.toHaveBeenCalled();
115
+ expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
116
+ expect(setTokens).toHaveBeenCalledWith('access-minted');
117
+ // The mint won first — the shared-key fallback was never reached.
118
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
142
119
  });
143
120
 
144
- it('stored-tokens rotates when the warm token is absent/expired', async () => {
121
+ it('skips (no mint) when only one of deviceId / deviceSecret is persisted', async () => {
145
122
  const store = createMemoryAuthStateStore();
146
- await store.save({ sessionId: 'sess-old', refreshToken: 'r-abcdefghij', userId: 'user-1' });
147
- const { oxy, setTokens } = makeOxy();
123
+ await store.save({ sessionId: 's', userId: 'u', deviceSecret: 'ds-only' });
124
+ const mintFromDeviceSecret = jest.fn(async () => MINT);
125
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
126
+ const onSignedOut = jest.fn();
148
127
 
149
- const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom });
128
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
150
129
 
151
- expect(outcome.kind).toBe('session');
152
- expect(setTokens).toHaveBeenCalledWith('access-rot');
153
- expect(await store.load()).toMatchObject({ sessionId: 'sess-rot', refreshToken: 'refresh-rot-abcdefghij' });
130
+ expect(mintFromDeviceSecret).not.toHaveBeenCalled();
131
+ // Nothing else can resolve on web → signed out.
132
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
133
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
154
134
  });
155
135
 
156
- it('bootstrap-return wins over later steps when a valid fragment is present', async () => {
157
- const store = createMemoryAuthStateStore();
158
- // A base64url fragment carrying reason:session + matching state.
159
- const frag = {
160
- v: 1,
161
- state: 'state-match',
162
- reason: 'session',
163
- code: 'c'.repeat(24),
164
- deviceToken: 'd'.repeat(24),
165
- };
166
- const b64 = Buffer.from(JSON.stringify(frag), 'utf-8')
167
- .toString('base64')
168
- .replace(/\+/g, '-')
169
- .replace(/\//g, '_')
170
- .replace(/=+$/, '');
171
- const domHandle = makeDom({ hash: `#oxy_boot=${b64}`, sessionState: 'state-match' });
172
- const { oxy, setTokens } = makeOxy();
173
- const onSession = jest.fn();
174
-
175
- const outcome = await runSessionColdBoot({
176
- oxy, store, platform: WEB, dom: domHandle.dom, onSession,
136
+ it('401 invalid_device_secret drops the secret (keeps deviceId) and ends signed out on web', async () => {
137
+ const { store, seed } = seedCredStore();
138
+ await seed();
139
+ const mintFromDeviceSecret = jest.fn(async () => {
140
+ throw mint401('invalid_device_secret');
177
141
  });
142
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
143
+ const onSignedOut = jest.fn();
178
144
 
179
- expect(outcome).toEqual({ kind: 'session', via: 'bootstrap-return', session: expect.any(Object) });
180
- expect(domHandle.strip).toHaveBeenCalled();
181
- expect(setTokens).toHaveBeenCalledWith('access-boot');
182
- expect(onSession).toHaveBeenCalledWith(expect.objectContaining({ via: 'bootstrap-return' }));
183
- });
184
- });
145
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
185
146
 
186
- describe('runSessionColdBoot — bootstrap-hop (web)', () => {
187
- it('same-apex: resolves a session via the inline web-session fetch (no navigation)', async () => {
188
- const store = createMemoryAuthStateStore();
189
- // The EXACT post-unwrap server shape (PR #526): a reason-`session` envelope
190
- // nesting the bundle under `session` plus the rotated deviceToken.
191
- const sessionEnvelope: WebSessionResult = { reason: 'session', session: BUNDLE, deviceToken: 'dt-rotated' };
192
- const requestWebSession = jest.fn(async () => sessionEnvelope);
193
- const { oxy, setTokens } = makeOxy({ requestWebSession });
194
- const domHandle = makeDom({ hostname: 'accounts.oxy.so' });
195
-
196
- const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom });
197
-
198
- expect(outcome).toEqual({ kind: 'session', via: 'bootstrap-hop', session: expect.any(Object) });
199
- expect(requestWebSession).toHaveBeenCalledTimes(1);
200
- expect(setTokens).toHaveBeenCalledWith('access-boot');
201
- expect(domHandle.navigate).not.toHaveBeenCalled();
202
- expect(await store.load()).toMatchObject({ sessionId: 'sess-boot', refreshToken: 'refresh-boot-abcdefghij' });
203
- // deviceToken from the SAME envelope is persisted on the session arm too.
204
- expect(await store.loadDeviceToken()).toBe('dt-rotated');
147
+ expect(mintFromDeviceSecret).toHaveBeenCalled();
148
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
149
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
150
+ const persisted = await store.load();
151
+ expect(persisted?.deviceSecret).toBeUndefined();
152
+ expect(persisted?.deviceId).toBe('dev-mint');
205
153
  });
206
154
 
207
- it('same-apex: signed-out device persists the deviceToken and reports signed out', async () => {
208
- const store = createMemoryAuthStateStore();
209
- const requestWebSession = jest.fn(async () => ({ reason: 'no_session', deviceToken: 'dt-web' }) as WebSessionResult);
210
- const { oxy } = makeOxy({ requestWebSession });
211
- const domHandle = makeDom({ hostname: 'accounts.oxy.so' });
155
+ it('401 no_active_session keeps the secret and reports signed out', async () => {
156
+ const { store, seed } = seedCredStore();
157
+ await seed();
158
+ const mintFromDeviceSecret = jest.fn(async () => {
159
+ throw mint401('no_active_session');
160
+ });
161
+ const signInWithSharedIdentity = jest.fn(async () => null);
162
+ const { oxy } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
212
163
  const onSignedOut = jest.fn();
213
164
 
214
- const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut });
165
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
215
166
 
216
167
  expect(outcome).toEqual({ kind: 'unauthenticated' });
217
168
  expect(onSignedOut).toHaveBeenCalledWith('no_session');
218
- expect(await store.loadDeviceToken()).toBe('dt-web');
219
- expect(domHandle.navigate).not.toHaveBeenCalled();
169
+ // The known-signed-out device retains its secret (it may sign in again).
170
+ expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
171
+ // Web has no shared-key lane.
172
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
220
173
  });
221
174
 
222
- it('cross-apex: navigates ONCE, stashing state + the attempted flag', async () => {
223
- const store = createMemoryAuthStateStore();
224
- const { oxy } = makeOxy();
225
- const domHandle = makeDom({ hostname: 'app.mention.earth' });
175
+ it('classifies a PLAIN-OBJECT 401 (no Error prototype) no_active_session still keeps the secret', async () => {
176
+ const { store, seed } = seedCredStore();
177
+ await seed();
178
+ // Cross-realm / ApiError-shaped throw: not `instanceof Error`. Must still be
179
+ // read as no_active_session — misreading it as a stale secret would drop it.
180
+ const mintFromDeviceSecret = jest.fn(async () => {
181
+ throw { status: 401, message: 'no_active_session' };
182
+ });
183
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
226
184
  const onSignedOut = jest.fn();
227
- const onSession = jest.fn();
228
185
 
229
- await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut, onSession });
186
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
230
187
 
231
- expect(domHandle.navigate).toHaveBeenCalledTimes(1);
232
- expect(domHandle.navigate).toHaveBeenCalledWith(expect.stringContaining('/auth/device/bootstrap?return_to='));
233
- expect(domHandle.session.get(BOOT_STATE_SESSION_KEY)).toBe('state-fixed-1234');
234
- expect(domHandle.local.get(BOOT_ATTEMPTED_KEY)).toBe('1');
235
- // Navigating away — neither callback fires (no signed-out flash).
236
- expect(onSignedOut).not.toHaveBeenCalled();
237
- expect(onSession).not.toHaveBeenCalled();
188
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
189
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
190
+ expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
238
191
  });
239
192
 
240
- it('cross-apex: NEVER navigates a second time once the attempted flag is set', async () => {
241
- const store = createMemoryAuthStateStore();
242
- const { oxy } = makeOxy();
243
- const domHandle = makeDom({ hostname: 'app.mention.earth', attempted: true });
193
+ it('transient (non-401) mint error keeps the secret and reports signed out on web', async () => {
194
+ const { store, seed } = seedCredStore();
195
+ await seed();
196
+ const mintFromDeviceSecret = jest.fn(async () => {
197
+ throw new Error('network down');
198
+ });
199
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
244
200
  const onSignedOut = jest.fn();
245
201
 
246
- const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut });
202
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
247
203
 
248
- expect(domHandle.navigate).not.toHaveBeenCalled();
249
204
  expect(outcome).toEqual({ kind: 'unauthenticated' });
250
205
  expect(onSignedOut).toHaveBeenCalledWith('no_session');
206
+ // The secret survives a transient failure so a later attempt can succeed.
207
+ expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
251
208
  });
252
- });
253
-
254
- describe('runSessionColdBoot — native shared-key', () => {
255
- afterEach(() => jest.restoreAllMocks());
256
209
 
257
- it('re-mints via shared identity and issues + mirrors a deviceToken the first time', async () => {
258
- const store = createMemoryAuthStateStore();
210
+ it('a dropped secret (401) on native falls through to the shared-key lane', async () => {
211
+ const { store, seed } = seedCredStore();
212
+ await seed();
259
213
  const sharedSession: SessionLoginResponse = {
260
214
  sessionId: 'sess-shared',
261
215
  deviceId: 'dev-1',
@@ -263,55 +217,102 @@ describe('runSessionColdBoot — native shared-key', () => {
263
217
  user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
264
218
  accessToken: 'access-shared',
265
219
  };
266
- const issueNativeDeviceToken = jest.fn(async () => 'fresh-device-token');
267
- const { oxy } = makeOxy({
268
- signInWithSharedIdentity: async () => sharedSession,
269
- issueNativeDeviceToken,
220
+ const mintFromDeviceSecret = jest.fn(async () => {
221
+ throw mint401('invalid_device_secret');
270
222
  });
271
- const getShared = jest.spyOn(KeyManager, 'getSharedDeviceToken').mockResolvedValue(null);
272
- const setShared = jest.spyOn(KeyManager, 'setSharedDeviceToken').mockResolvedValue(undefined);
273
- const requestWebSession = jest.spyOn(oxy, 'requestWebSession');
223
+ const signInWithSharedIdentity = jest.fn(async () => sharedSession);
224
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
225
+
226
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
227
+
228
+ expect(outcome).toEqual({ kind: 'session', via: 'shared-key-signin', session: expect.any(Object) });
229
+ expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
230
+ // The mint lane still dropped the stale secret before falling through.
231
+ expect((await store.load())?.deviceSecret).toBeUndefined();
232
+ // Shared-key plants tokens itself (via verifyChallenge); the cold boot does not.
233
+ expect(setTokens).not.toHaveBeenCalled();
234
+ });
235
+ });
236
+
237
+ describe('runSessionColdBoot — shared-key-signin (native)', () => {
238
+ const sharedSession: SessionLoginResponse = {
239
+ sessionId: 'sess-shared',
240
+ deviceId: 'dev-1',
241
+ expiresAt: '2030-01-01T00:00:00.000Z',
242
+ user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
243
+ accessToken: 'access-shared',
244
+ };
274
245
 
275
- const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, dom: makeDom().dom });
246
+ it('re-mints via the shared identity when there is no persisted mint credential', async () => {
247
+ const store = createMemoryAuthStateStore(); // no deviceId/deviceSecret
248
+ const signInWithSharedIdentity = jest.fn(async () => sharedSession);
249
+ const { oxy } = makeOxy({ signInWithSharedIdentity });
250
+ const onSession = jest.fn();
251
+
252
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, onSession });
276
253
 
277
254
  expect(outcome).toEqual({ kind: 'session', via: 'shared-key-signin', session: expect.any(Object) });
278
- expect(getShared).toHaveBeenCalled();
279
- expect(issueNativeDeviceToken).toHaveBeenCalled();
280
- expect(setShared).toHaveBeenCalledWith('fresh-device-token');
281
- expect(await store.loadDeviceToken()).toBe('fresh-device-token');
282
- // bootstrap-hop is web-only — never runs on native.
283
- expect(requestWebSession).not.toHaveBeenCalled();
255
+ expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
256
+ expect(onSession).toHaveBeenCalledWith(
257
+ expect.objectContaining({ sessionId: 'sess-shared', userId: 'user-shared', via: 'shared-key-signin' }),
258
+ );
284
259
  });
285
260
 
286
- it('skips device-token issuance when the shared token already exists', async () => {
261
+ it('does NOT run the shared-key lane on web', async () => {
287
262
  const store = createMemoryAuthStateStore();
288
- const issueNativeDeviceToken = jest.fn(async () => 'unused');
289
- const { oxy } = makeOxy({
290
- signInWithSharedIdentity: async () => ({
291
- sessionId: 'sess-shared',
292
- deviceId: 'dev-1',
293
- expiresAt: '2030-01-01T00:00:00.000Z',
294
- user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
295
- accessToken: 'access-shared',
296
- }),
297
- issueNativeDeviceToken,
263
+ const signInWithSharedIdentity = jest.fn(async () => sharedSession);
264
+ const { oxy } = makeOxy({ signInWithSharedIdentity });
265
+ const onSignedOut = jest.fn();
266
+
267
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
268
+
269
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
270
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
271
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
272
+ });
273
+
274
+ it('reports signed out when the shared identity yields no session', async () => {
275
+ const store = createMemoryAuthStateStore();
276
+ const signInWithSharedIdentity = jest.fn(async () => null);
277
+ const { oxy } = makeOxy({ signInWithSharedIdentity });
278
+ const onSignedOut = jest.fn();
279
+
280
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, onSignedOut });
281
+
282
+ expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
283
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
284
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
285
+ });
286
+
287
+ it('reports a step error (onStepError + signed-out reason `error`) when the shared identity throws', async () => {
288
+ const store = createMemoryAuthStateStore();
289
+ const signInWithSharedIdentity = jest.fn(async () => {
290
+ throw new Error('keychain locked');
298
291
  });
299
- jest.spyOn(KeyManager, 'getSharedDeviceToken').mockResolvedValue('already-there');
292
+ const { oxy } = makeOxy({ signInWithSharedIdentity });
293
+ const onSignedOut = jest.fn();
294
+ const onStepError = jest.fn();
300
295
 
301
- await runSessionColdBoot({ oxy, store, platform: NATIVE, dom: makeDom().dom });
296
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, onSignedOut, onStepError });
302
297
 
303
- expect(issueNativeDeviceToken).not.toHaveBeenCalled();
298
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
299
+ expect(onStepError).toHaveBeenCalledWith('shared-key-signin', expect.any(Error));
300
+ expect(onSignedOut).toHaveBeenCalledWith('error');
304
301
  });
305
302
  });
306
303
 
307
- describe('createBrowserColdBootDom', () => {
308
- it('returns neutral values under the jest node environment (no window)', () => {
309
- const dom = createBrowserColdBootDom();
310
- expect(dom.getHash()).toBe('');
311
- expect(dom.getLocationHostname()).toBeNull();
312
- expect(dom.getSessionItem('x')).toBeNull();
313
- expect(() => dom.stripFragment()).not.toThrow();
314
- expect(() => dom.navigate('https://x')).not.toThrow();
315
- expect(dom.randomState()).toMatch(/^[0-9a-f]+$/);
304
+ describe('runSessionColdBoot — signed out', () => {
305
+ it('reports signed out when the store is empty and no lane resolves (web)', async () => {
306
+ const store = createMemoryAuthStateStore();
307
+ const { oxy, setTokens } = makeOxy();
308
+ const onSession = jest.fn();
309
+ const onSignedOut = jest.fn();
310
+
311
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSession, onSignedOut });
312
+
313
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
314
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
315
+ expect(onSession).not.toHaveBeenCalled();
316
+ expect(setTokens).not.toHaveBeenCalled();
316
317
  });
317
318
  });