@oxyhq/core 20.1.0 → 21.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 (70) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +107 -8
  3. package/dist/cjs/i18n/locales/en-US.json +19 -2
  4. package/dist/cjs/i18n/locales/es-ES.json +19 -2
  5. package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
  7. package/dist/cjs/index.js +50 -16
  8. package/dist/cjs/mixins/OxyServices.auth.js +27 -3
  9. package/dist/cjs/session/SessionClient.js +361 -1
  10. package/dist/cjs/session/accountDialogController.js +121 -147
  11. package/dist/cjs/session/accountSwitchTargets.js +75 -0
  12. package/dist/cjs/session/deviceDirectory.js +143 -0
  13. package/dist/cjs/session/deviceSwitcherRows.js +76 -0
  14. package/dist/cjs/session/projectSessionState.js +8 -1
  15. package/dist/cjs/session/sharedDeviceCredential.js +247 -0
  16. package/dist/esm/.tsbuildinfo +1 -1
  17. package/dist/esm/boot/sessionColdBoot.js +107 -8
  18. package/dist/esm/i18n/locales/en-US.json +19 -2
  19. package/dist/esm/i18n/locales/es-ES.json +19 -2
  20. package/dist/esm/i18n/locales/locales/en-US.json +19 -2
  21. package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
  22. package/dist/esm/index.js +32 -10
  23. package/dist/esm/mixins/OxyServices.auth.js +27 -3
  24. package/dist/esm/session/SessionClient.js +362 -2
  25. package/dist/esm/session/accountDialogController.js +121 -147
  26. package/dist/esm/session/accountSwitchTargets.js +71 -0
  27. package/dist/esm/session/deviceDirectory.js +135 -0
  28. package/dist/esm/session/deviceSwitcherRows.js +72 -0
  29. package/dist/esm/session/projectSessionState.js +8 -2
  30. package/dist/esm/session/sharedDeviceCredential.js +239 -0
  31. package/dist/types/.tsbuildinfo +1 -1
  32. package/dist/types/boot/sessionColdBoot.d.ts +24 -4
  33. package/dist/types/index.d.ts +8 -3
  34. package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
  35. package/dist/types/models/session.d.ts +11 -0
  36. package/dist/types/session/SessionClient.d.ts +202 -1
  37. package/dist/types/session/accountDialogController.d.ts +76 -64
  38. package/dist/types/session/accountSwitchTargets.d.ts +64 -0
  39. package/dist/types/session/deviceDirectory.d.ts +182 -0
  40. package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
  41. package/dist/types/session/projectSessionState.d.ts +29 -0
  42. package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
  43. package/package.json +3 -3
  44. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
  45. package/src/boot/sessionColdBoot.ts +133 -9
  46. package/src/i18n/locales/en-US.json +19 -2
  47. package/src/i18n/locales/es-ES.json +19 -2
  48. package/src/index.ts +75 -18
  49. package/src/mixins/OxyServices.auth.ts +67 -5
  50. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
  51. package/src/models/session.ts +11 -0
  52. package/src/session/SessionClient.ts +386 -1
  53. package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
  54. package/src/session/__tests__/accountDialogController.test.ts +411 -278
  55. package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
  56. package/src/session/__tests__/deviceDirectory.test.ts +422 -0
  57. package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
  58. package/src/session/__tests__/projectSessionState.test.ts +17 -0
  59. package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
  60. package/src/session/accountDialogController.ts +141 -179
  61. package/src/session/accountSwitchTargets.ts +87 -0
  62. package/src/session/deviceDirectory.ts +269 -0
  63. package/src/session/deviceSwitcherRows.ts +145 -0
  64. package/src/session/projectSessionState.ts +9 -3
  65. package/src/session/sharedDeviceCredential.ts +349 -0
  66. package/dist/cjs/session/accountProjection.js +0 -213
  67. package/dist/esm/session/accountProjection.js +0 -207
  68. package/dist/types/session/accountProjection.d.ts +0 -198
  69. package/src/session/__tests__/accountProjection.test.ts +0 -447
  70. package/src/session/accountProjection.ts +0 -354
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The shared native DeviceSession credential — one device, one session, many apps.
3
+ *
4
+ * ## What this is, and what it deliberately is NOT
5
+ *
6
+ * Two different secrets can make a native Oxy app boot signed in, and conflating
7
+ * them is the bug this module exists to end:
8
+ *
9
+ * Commons private identity key → identity and signed approval ONLY. It is
10
+ * self-custody, irreplaceable, and must never
11
+ * become the general app session transport.
12
+ * Shared DeviceSession credential → THIS module. An ordinary `deviceId` +
13
+ * `deviceSecret` pair that restores ordinary
14
+ * official apps, follows the device's active
15
+ * context, and is individually rotatable and
16
+ * revocable server-side.
17
+ *
18
+ * An ordinary app needs the second one. Handing it the first — which is what the
19
+ * `shared-key-signin` lane does today — gives every sibling app the ability to
20
+ * sign as the user's cryptographic identity to obtain something as mundane as a
21
+ * session. That lane stays as a recovery/compatibility path; this one supersedes
22
+ * it for the ordinary case.
23
+ *
24
+ * ## Why sharing one credential is safe against the server
25
+ *
26
+ * `POST /session/device/token` does NOT rotate: the response echoes the presented
27
+ * secret back as `nextDeviceSecret` precisely so several first-party apps sharing
28
+ * one `DeviceSession` can refresh concurrently without invalidating one another.
29
+ * So N apps holding one credential is a supported server state, not a race — and
30
+ * because they then share ONE `DeviceSession`, they automatically share its
31
+ * `activeContextId` and its token-free `session_state` broadcasts.
32
+ *
33
+ * ## The one safety rule everything here is built around
34
+ *
35
+ * A read that FAILED and a slot that is EMPTY must never be the same value. The
36
+ * empty answer authorises writes (seed the slot); the failed answer must
37
+ * authorise nothing. {@link SharedDeviceCredentialRead} keeps them apart at the
38
+ * type level, and every decision below fails closed on anything that is not a
39
+ * positive `absent`/`present`.
40
+ *
41
+ * Platform-agnostic: the actual keychain / keystore access is injected as a
42
+ * {@link SharedDeviceCredentialStore} by `@oxyhq/services`. ESM-safe, no
43
+ * `require()`, no react/react-native/expo imports.
44
+ */
45
+ import type { AuthStateStore, PersistedAuthState } from './authStateStore';
46
+ /**
47
+ * The zero-cookie device credential, as shared between apps. Exactly the pair
48
+ * `POST /session/device/token` takes — nothing else travels through the shared
49
+ * slot: no access token, no account id, no user id, no identity key.
50
+ */
51
+ export interface SharedDeviceCredential {
52
+ deviceId: string;
53
+ deviceSecret: string;
54
+ }
55
+ /**
56
+ * The outcome of reading the shared slot. FOUR states, and the distinction
57
+ * between the last two is load-bearing:
58
+ *
59
+ * - `present` — a well-formed credential was read.
60
+ * - `absent` — the read SUCCEEDED and the slot is empty. The only state that
61
+ * may authorise seeding the slot.
62
+ * - `unavailable` — the read failed (keychain locked, keystore unreadable, the
63
+ * bridge returned something unrecognisable). Authorises
64
+ * nothing: not adoption, and above all not a write.
65
+ * - `unsupported` — this build has no shared slot at all (web, or a native app
66
+ * without the module linked). Not an error; simply means the
67
+ * app keeps its own per-app credential.
68
+ */
69
+ export type SharedDeviceCredentialRead = {
70
+ state: 'present';
71
+ credential: SharedDeviceCredential;
72
+ } | {
73
+ state: 'absent';
74
+ } | {
75
+ state: 'unavailable';
76
+ cause: unknown;
77
+ } | {
78
+ state: 'unsupported';
79
+ };
80
+ /**
81
+ * The platform seam. `@oxyhq/services` implements this over the iOS Keychain
82
+ * Access Group (a dedicated `keychainService`) or the Android signature-protected
83
+ * `OxyDeviceSession` broker.
84
+ */
85
+ export interface SharedDeviceCredentialStore {
86
+ /** Never throws — a failure is reported as `unavailable`, never as `absent`. */
87
+ read(): Promise<SharedDeviceCredentialRead>;
88
+ /**
89
+ * Publish the credential. Resolves `true` only when a read-back confirmed the
90
+ * exact bytes landed; `false` on any failure. Never throws.
91
+ */
92
+ publish(credential: SharedDeviceCredential): Promise<boolean>;
93
+ /** Drop this app's copy of the shared credential. Never throws. */
94
+ clear(): Promise<void>;
95
+ }
96
+ /** Why {@link decideSharedDeviceJoin} declined to adopt the shared credential. */
97
+ export type SharedDeviceJoinSkipReason = 'shared-unsupported' | 'shared-unreadable' | 'shared-empty' | 'local-credential-present';
98
+ /** What a booting app should do with the shared slot it just read. */
99
+ export type SharedDeviceJoinDecision = {
100
+ action: 'adopt';
101
+ credential: SharedDeviceCredential;
102
+ } | {
103
+ action: 'skip';
104
+ reason: SharedDeviceJoinSkipReason;
105
+ };
106
+ /** Why {@link decideSharedDevicePublish} declined to write the shared slot. */
107
+ export type SharedDevicePublishSkipReason = 'shared-unsupported' | 'shared-unreadable' | 'already-current' | 'owned-by-another-device';
108
+ /** What an app that just PROVED a credential should do with the shared slot. */
109
+ export type SharedDevicePublishDecision = {
110
+ action: 'publish';
111
+ } | {
112
+ action: 'skip';
113
+ reason: SharedDevicePublishSkipReason;
114
+ };
115
+ /** The usable `{deviceId, deviceSecret}` pair in a persisted state, or null. */
116
+ export declare function readLocalDeviceCredential(state: PersistedAuthState | null): SharedDeviceCredential | null;
117
+ /**
118
+ * Narrow an UNTRUSTED bridge payload into a {@link SharedDeviceCredentialRead}.
119
+ *
120
+ * Anything unrecognised resolves to `unavailable`, never `absent`. A native
121
+ * module returning a shape this build does not understand (an older app in the
122
+ * signing group, a partially-applied upgrade) means we do not KNOW whether the
123
+ * device has a shared session — and "do not know" must never authorise a write
124
+ * that would overwrite one.
125
+ */
126
+ export declare function normalizeSharedDeviceSessionRead(raw: unknown): SharedDeviceCredentialRead;
127
+ /**
128
+ * Should this app adopt the shared credential? Pure.
129
+ *
130
+ * Adoption happens in exactly ONE case: the shared slot holds a credential and
131
+ * this app has none of its own. That single rule delivers the product
132
+ * requirement — a newly installed official app joins the device's existing
133
+ * session without another QR — while making the two failure modes that matter
134
+ * unreachable:
135
+ *
136
+ * - An app that is already signed in is NEVER moved onto another credential, so
137
+ * "no user is signed out merely because one app updates first" holds by
138
+ * construction, in both upgrade directions.
139
+ * - A failed read never looks like an empty slot, so a locked keychain resolves
140
+ * to "keep what I have" rather than "this is a fresh device".
141
+ *
142
+ * The cost is stated plainly: two apps that each already own a DIFFERENT device
143
+ * session stay on their own until one of them loses its credential. Converging
144
+ * them would mean signing one of them out or a server-side device merge, and
145
+ * neither is something a boot path may do silently.
146
+ */
147
+ export declare function decideSharedDeviceJoin(local: PersistedAuthState | null, shared: SharedDeviceCredentialRead): SharedDeviceJoinDecision;
148
+ /**
149
+ * Should this app write the credential it just proved into the shared slot? Pure.
150
+ *
151
+ * `proven` means the server accepted it moments ago — a successful sign-in or a
152
+ * successful mint. Only a proven credential is ever published, so the slot can
153
+ * never be seeded with something no app could use.
154
+ *
155
+ * A slot already held by a DIFFERENT `deviceId` is left alone. Overwriting it
156
+ * would silently migrate every other app on this device onto our session at their
157
+ * next cold boot — a real, user-visible change of who they are signed in as, and
158
+ * not something a background persist may decide.
159
+ */
160
+ export declare function decideSharedDevicePublish(proven: SharedDeviceCredential, shared: SharedDeviceCredentialRead): SharedDevicePublishDecision;
161
+ /** The result of {@link publishProvenDeviceCredential}, for logs and tests. */
162
+ export type SharedDevicePublishOutcome = {
163
+ status: 'published';
164
+ } | {
165
+ status: 'publish-failed';
166
+ } | {
167
+ status: 'skipped';
168
+ reason: SharedDevicePublishSkipReason;
169
+ };
170
+ /**
171
+ * Read the shared slot, apply {@link decideSharedDevicePublish}, and write when
172
+ * it says so. Best-effort by contract: the caller's own durable credential is
173
+ * already persisted, so a failure here only means a future install will have to
174
+ * sign in interactively.
175
+ */
176
+ export declare function publishProvenDeviceCredential(deps: {
177
+ shared: SharedDeviceCredentialStore;
178
+ credential: SharedDeviceCredential;
179
+ }): Promise<SharedDevicePublishOutcome>;
180
+ /**
181
+ * Wrap a platform {@link AuthStateStore} so that every durable credential it
182
+ * persists is ALSO mirrored into the shared slot.
183
+ *
184
+ * Writes mirror automatically; reads do NOT adopt. That split is deliberate:
185
+ *
186
+ * - Mirroring on write is the right place because `save()` is where a proven
187
+ * credential lands, on every lane there is — interactive sign-in, the cold
188
+ * boot mint, the refresh scheduler, the 401 re-mint, shared-key recovery. One
189
+ * seam, no lane left out, and no new call site to forget.
190
+ * - Adopting on read would hide a change of WHO THIS APP IS SIGNED IN AS inside
191
+ * a storage primitive, and would run on every `load()`. Adoption is an
192
+ * explicit, once-per-boot cold-boot step instead (`shared-device-adopt`).
193
+ *
194
+ * `clear()` deliberately does NOT clear the shared slot. This app signing out is
195
+ * not authority over the device-wide join point: other apps may still be signed
196
+ * in on that same credential, and once the server session is really gone the
197
+ * credential mints `no_active_session` for everyone anyway.
198
+ */
199
+ export declare function createSharedMirroringAuthStateStore(deps: {
200
+ local: AuthStateStore;
201
+ shared: SharedDeviceCredentialStore;
202
+ }): AuthStateStore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "20.1.0",
3
+ "version": "21.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",
@@ -116,7 +116,7 @@
116
116
  "dependencies": {
117
117
  "@noble/ciphers": "^1.3.0",
118
118
  "@noble/hashes": "^1.8.0",
119
- "@oxyhq/contracts": "^0.25.0",
119
+ "@oxyhq/contracts": "^0.26.0",
120
120
  "@oxyhq/protocol": "^0.2.0",
121
121
  "@scure/bip39": "^1.6.0",
122
122
  "@types/elliptic": "^6.4.18",
@@ -127,7 +127,7 @@
127
127
  "invariant": "^2.2.4",
128
128
  "jwt-decode": "^4.0.0",
129
129
  "socket.io-client": "^4.8.1",
130
- "tldts": "^7.4.8",
130
+ "tldts": "^7.4.10",
131
131
  "zod": "^3.25.64"
132
132
  },
133
133
  "peerDependencies": {
@@ -0,0 +1,325 @@
1
+ /**
2
+ * `shared-device-adopt` — the cold-boot lane that lets a freshly installed
3
+ * official app join the device's EXISTING session with no QR and, crucially,
4
+ * with no access to the Commons private identity key.
5
+ *
6
+ * What is pinned here:
7
+ * - a fresh install adopts the shared credential and mints;
8
+ * - an app that already has a credential is never moved (upgrade order cannot
9
+ * sign anyone out, in either direction);
10
+ * - an UNREADABLE shared slot is not a fresh device — the lane skips and the
11
+ * legacy identity-key lane still gets its turn;
12
+ * - a failed adoption leaves the local store byte-for-byte as it was;
13
+ * - `sessionMode: 'identity'` never runs the lane at all;
14
+ * - web never runs it.
15
+ */
16
+ import type { OxyServices } from '../../OxyServices';
17
+ import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
18
+ import type { SessionLoginResponse } from '../../models/session';
19
+ import { runSessionColdBoot } from '../sessionColdBoot';
20
+ import type { DeviceSecretMintOutcome } from '../../session/refresh';
21
+ import { createMemoryAuthStateStore, type PersistedAuthState } from '../../session/authStateStore';
22
+ import type {
23
+ SharedDeviceCredentialRead,
24
+ SharedDeviceCredentialStore,
25
+ } from '../../session/sharedDeviceCredential';
26
+ import { createMemoryIdentityPinStore } from '../../session/identityPin';
27
+ import type { IdentityBinding } from '../../session/identitySession';
28
+
29
+ const WEB = { isWeb: true, isNative: false };
30
+ const NATIVE = { isWeb: false, isNative: true };
31
+
32
+ const SHARED_CRED = { deviceId: 'dev-shared', deviceSecret: 'ds-shared' };
33
+ const OWN_CRED = { deviceId: 'dev-own', deviceSecret: 'ds-own' };
34
+
35
+ const MINT: DeviceTokenMintResponse = {
36
+ accessToken: 'access-joined',
37
+ expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
38
+ nextDeviceSecret: SHARED_CRED.deviceSecret,
39
+ state: {
40
+ deviceId: SHARED_CRED.deviceId,
41
+ accounts: [{ accountId: 'user-shared', sessionId: 'sess-shared', authuser: 0 }],
42
+ activeAccountId: 'user-shared',
43
+ revision: 9,
44
+ updatedAt: 1_700_000_000_000,
45
+ },
46
+ };
47
+
48
+ /** A real device-secret mint single-flight matching HttpService's. */
49
+ function makeMintSingleFlight(): (mint: () => Promise<DeviceSecretMintOutcome>) => Promise<DeviceSecretMintOutcome> {
50
+ let inFlight: Promise<DeviceSecretMintOutcome> | null = null;
51
+ return (mint) => {
52
+ if (!inFlight) {
53
+ inFlight = mint().finally(() => {
54
+ inFlight = null;
55
+ });
56
+ }
57
+ return inFlight;
58
+ };
59
+ }
60
+
61
+ /** A 401 error shaped like `HttpService`/`handleError` output for the given body. */
62
+ function mint401(body: string): Error & { status: number } {
63
+ return Object.assign(new Error(body), { status: 401 });
64
+ }
65
+
66
+ function makeOxy(overrides: {
67
+ mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
68
+ signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
69
+ } = {}) {
70
+ const setTokens = jest.fn();
71
+ const mintFromDeviceSecret = jest.fn(
72
+ overrides.mintFromDeviceSecret ?? (async () => MINT),
73
+ ) as unknown as OxyServices['mintFromDeviceSecret'];
74
+ const signInWithSharedIdentity = jest.fn(
75
+ overrides.signInWithSharedIdentity ?? (async () => null),
76
+ ) as unknown as OxyServices['signInWithSharedIdentity'];
77
+ const oxy = {
78
+ getBaseURL: () => 'https://api.oxy.so',
79
+ setTokens,
80
+ mintFromDeviceSecret,
81
+ signInWithSharedIdentity,
82
+ httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
83
+ } as unknown as OxyServices;
84
+ return { oxy, setTokens, mintFromDeviceSecret, signInWithSharedIdentity };
85
+ }
86
+
87
+ function makeSharedSlot(initial: SharedDeviceCredentialRead) {
88
+ let current = initial;
89
+ const read = jest.fn(async () => current);
90
+ const publish = jest.fn(async () => true);
91
+ const clear = jest.fn(async () => {
92
+ current = { state: 'absent' };
93
+ });
94
+ const store: SharedDeviceCredentialStore = { read, publish, clear };
95
+ return { store, read, publish, clear };
96
+ }
97
+
98
+ /** An identity binding that resolves a local key, for the identity-mode case. */
99
+ function makeIdentityBinding(): IdentityBinding {
100
+ return {
101
+ pinStore: createMemoryIdentityPinStore(),
102
+ getPublicKey: async () => 'pub-identity',
103
+ signMessage: async () => 'sig-identity',
104
+ };
105
+ }
106
+
107
+ describe('cold boot — shared-device-adopt', () => {
108
+ test('a fresh install adopts the shared credential and mints a session', async () => {
109
+ const store = createMemoryAuthStateStore();
110
+ const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
111
+ const { oxy, mintFromDeviceSecret, setTokens } = makeOxy();
112
+
113
+ const outcome = await runSessionColdBoot({
114
+ oxy,
115
+ store,
116
+ platform: NATIVE,
117
+ sharedDeviceCredential: slot.store,
118
+ });
119
+
120
+ expect(outcome).toMatchObject({ kind: 'session', via: 'shared-device-adopt' });
121
+ expect(mintFromDeviceSecret).toHaveBeenCalledWith(SHARED_CRED.deviceId, SHARED_CRED.deviceSecret);
122
+ expect(setTokens).toHaveBeenCalledWith(MINT.accessToken);
123
+ // The adopted credential is now this app's own, so the next boot takes the
124
+ // faster `device-secret-mint` lane.
125
+ expect(await store.load()).toMatchObject({
126
+ deviceId: SHARED_CRED.deviceId,
127
+ deviceSecret: SHARED_CRED.deviceSecret,
128
+ sessionId: 'sess-shared',
129
+ userId: 'user-shared',
130
+ });
131
+ });
132
+
133
+ test('the identity key is never touched when the shared credential works', async () => {
134
+ // The whole point of the separation: an ordinary app joins the device session
135
+ // without ever asking for the key that signs identity approvals.
136
+ const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
137
+ const { oxy, signInWithSharedIdentity } = makeOxy();
138
+
139
+ await runSessionColdBoot({
140
+ oxy,
141
+ store: createMemoryAuthStateStore(),
142
+ platform: NATIVE,
143
+ sharedDeviceCredential: slot.store,
144
+ });
145
+
146
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
147
+ });
148
+
149
+ test('an app that already holds its own credential is not moved onto the shared one', async () => {
150
+ // Upgrade order must not sign anyone out. This app's own session wins.
151
+ const store = createMemoryAuthStateStore();
152
+ await store.save({ sessionId: 'sess-own', userId: 'user-own', ...OWN_CRED });
153
+ const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
154
+ const { oxy, mintFromDeviceSecret } = makeOxy({
155
+ mintFromDeviceSecret: (async () => ({
156
+ ...MINT,
157
+ nextDeviceSecret: OWN_CRED.deviceSecret,
158
+ state: { ...MINT.state, deviceId: OWN_CRED.deviceId },
159
+ })) as unknown as OxyServices['mintFromDeviceSecret'],
160
+ });
161
+
162
+ const outcome = await runSessionColdBoot({
163
+ oxy,
164
+ store,
165
+ platform: NATIVE,
166
+ sharedDeviceCredential: slot.store,
167
+ });
168
+
169
+ expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
170
+ expect(mintFromDeviceSecret).toHaveBeenCalledWith(OWN_CRED.deviceId, OWN_CRED.deviceSecret);
171
+ expect(mintFromDeviceSecret).not.toHaveBeenCalledWith(SHARED_CRED.deviceId, SHARED_CRED.deviceSecret);
172
+ expect(await store.load()).toMatchObject({ deviceId: OWN_CRED.deviceId });
173
+ });
174
+
175
+ test('an UNREADABLE shared slot is not treated as a fresh device', async () => {
176
+ // A locked keystore must never authorise anything. The lane skips, the store
177
+ // is untouched, and the legacy identity lane still gets its turn — which is
178
+ // what keeps a momentarily-locked device from silently onboarding again.
179
+ const store = createMemoryAuthStateStore();
180
+ const slot = makeSharedSlot({ state: 'unavailable', cause: new Error('keystore locked') });
181
+ const sharedKeySession = {
182
+ sessionId: 'sess-legacy',
183
+ user: { id: 'user-legacy' },
184
+ accessToken: 'access-legacy',
185
+ expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
186
+ deviceId: 'dev-legacy',
187
+ deviceSecret: 'ds-legacy',
188
+ } as unknown as SessionLoginResponse;
189
+ const { oxy, signInWithSharedIdentity } = makeOxy({
190
+ signInWithSharedIdentity: (async () => sharedKeySession) as unknown as OxyServices['signInWithSharedIdentity'],
191
+ });
192
+
193
+ const outcome = await runSessionColdBoot({
194
+ oxy,
195
+ store,
196
+ platform: NATIVE,
197
+ sharedDeviceCredential: slot.store,
198
+ });
199
+
200
+ expect(slot.publish).not.toHaveBeenCalled();
201
+ expect(slot.clear).not.toHaveBeenCalled();
202
+ expect(signInWithSharedIdentity).toHaveBeenCalled();
203
+ expect(outcome).toMatchObject({ kind: 'session', via: 'shared-key-signin' });
204
+ });
205
+
206
+ test('an empty shared slot falls through to the legacy identity lane', async () => {
207
+ const slot = makeSharedSlot({ state: 'absent' });
208
+ const { oxy, signInWithSharedIdentity } = makeOxy();
209
+
210
+ const outcome = await runSessionColdBoot({
211
+ oxy,
212
+ store: createMemoryAuthStateStore(),
213
+ platform: NATIVE,
214
+ sharedDeviceCredential: slot.store,
215
+ });
216
+
217
+ expect(signInWithSharedIdentity).toHaveBeenCalled();
218
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
219
+ });
220
+
221
+ test('a rejected shared credential is reverted locally and cleared from the slot', async () => {
222
+ // `invalid_device_secret` is the one positive proof that the exact shared
223
+ // bytes are dead. Reverting keeps this app where it was; clearing the slot is
224
+ // what stops a dead credential from blocking every future install.
225
+ const store = createMemoryAuthStateStore();
226
+ const before: PersistedAuthState = { sessionId: 'sess-stale', userId: 'user-stale' };
227
+ await store.save(before);
228
+ const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
229
+ const { oxy } = makeOxy({
230
+ mintFromDeviceSecret: (async () => {
231
+ throw mint401('invalid_device_secret');
232
+ }) as unknown as OxyServices['mintFromDeviceSecret'],
233
+ });
234
+
235
+ const outcome = await runSessionColdBoot({
236
+ oxy,
237
+ store,
238
+ platform: NATIVE,
239
+ sharedDeviceCredential: slot.store,
240
+ });
241
+
242
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
243
+ expect(slot.clear).toHaveBeenCalledTimes(1);
244
+ expect(await store.load()).toEqual(before);
245
+ });
246
+
247
+ test('a transient mint failure reverts without clearing the shared slot', async () => {
248
+ // A network blip says nothing about the credential. Wiping the device-wide
249
+ // join point on an ambiguous failure is the deploy-window bug, one layer up.
250
+ const store = createMemoryAuthStateStore();
251
+ const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
252
+ const { oxy } = makeOxy({
253
+ mintFromDeviceSecret: (async () => {
254
+ throw new Error('network down');
255
+ }) as unknown as OxyServices['mintFromDeviceSecret'],
256
+ });
257
+
258
+ await runSessionColdBoot({ oxy, store, platform: NATIVE, sharedDeviceCredential: slot.store });
259
+
260
+ expect(slot.clear).not.toHaveBeenCalled();
261
+ expect(await store.load()).toBeNull();
262
+ });
263
+
264
+ test('the lane does not run on web', async () => {
265
+ const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
266
+ const { oxy, mintFromDeviceSecret } = makeOxy();
267
+
268
+ const outcome = await runSessionColdBoot({
269
+ oxy,
270
+ store: createMemoryAuthStateStore(),
271
+ platform: WEB,
272
+ sharedDeviceCredential: slot.store,
273
+ });
274
+
275
+ expect(slot.read).not.toHaveBeenCalled();
276
+ expect(mintFromDeviceSecret).not.toHaveBeenCalled();
277
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
278
+ });
279
+
280
+ test('the lane does not run when the device reports itself offline', async () => {
281
+ const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
282
+ const { oxy } = makeOxy();
283
+
284
+ await runSessionColdBoot({
285
+ oxy,
286
+ store: createMemoryAuthStateStore(),
287
+ platform: NATIVE,
288
+ isOffline: () => true,
289
+ sharedDeviceCredential: slot.store,
290
+ });
291
+
292
+ expect(slot.read).not.toHaveBeenCalled();
293
+ });
294
+
295
+ test('identity mode never reads the shared slot', async () => {
296
+ // The slot belongs to whichever principal signed in on this device. An
297
+ // identity-bound client resolves its session from the local key alone —
298
+ // adopting a device credential is the drift that mode exists to prevent.
299
+ const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
300
+ const { oxy } = makeOxy();
301
+
302
+ await runSessionColdBoot({
303
+ oxy,
304
+ store: createMemoryAuthStateStore(),
305
+ platform: NATIVE,
306
+ sessionMode: 'identity',
307
+ identity: makeIdentityBinding(),
308
+ sharedDeviceCredential: slot.store,
309
+ });
310
+
311
+ expect(slot.read).not.toHaveBeenCalled();
312
+ expect(slot.publish).not.toHaveBeenCalled();
313
+ });
314
+
315
+ test('omitting the slot leaves the boot chain exactly as it was', async () => {
316
+ const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
317
+ const { oxy, signInWithSharedIdentity } = makeOxy();
318
+
319
+ const outcome = await runSessionColdBoot({ oxy, store: createMemoryAuthStateStore(), platform: NATIVE });
320
+
321
+ expect(slot.read).not.toHaveBeenCalled();
322
+ expect(signInWithSharedIdentity).toHaveBeenCalled();
323
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
324
+ });
325
+ });