@oxyhq/core 12.10.6 → 12.11.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 (126) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +142 -37
  3. package/dist/cjs/i18n/locales/ar-SA.json +13 -1
  4. package/dist/cjs/i18n/locales/ca-ES.json +13 -1
  5. package/dist/cjs/i18n/locales/de-DE.json +13 -1
  6. package/dist/cjs/i18n/locales/en-US.json +13 -4
  7. package/dist/cjs/i18n/locales/es-ES.json +13 -4
  8. package/dist/cjs/i18n/locales/fr-FR.json +13 -1
  9. package/dist/cjs/i18n/locales/it-IT.json +13 -1
  10. package/dist/cjs/i18n/locales/ja-JP.json +13 -1
  11. package/dist/cjs/i18n/locales/ko-KR.json +13 -1
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
  15. package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
  23. package/dist/cjs/i18n/locales/pt-PT.json +13 -1
  24. package/dist/cjs/i18n/locales/zh-CN.json +13 -1
  25. package/dist/cjs/index.js +31 -5
  26. package/dist/cjs/mixins/OxyServices.auth.js +257 -17
  27. package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
  28. package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
  29. package/dist/cjs/mixins/index.js +5 -0
  30. package/dist/cjs/session/SessionClient.js +40 -4
  31. package/dist/cjs/session/accountDialogController.js +288 -41
  32. package/dist/cjs/session/identityPin.js +256 -0
  33. package/dist/cjs/session/identitySession.js +135 -0
  34. package/dist/cjs/session/projectSessionState.js +54 -15
  35. package/dist/cjs/session/refresh.js +73 -8
  36. package/dist/cjs/utils/commonsDelivery.js +59 -0
  37. package/dist/cjs/utils/oauthPkce.js +33 -3
  38. package/dist/esm/.tsbuildinfo +1 -1
  39. package/dist/esm/boot/sessionColdBoot.js +142 -37
  40. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  41. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  42. package/dist/esm/i18n/locales/de-DE.json +13 -1
  43. package/dist/esm/i18n/locales/en-US.json +13 -4
  44. package/dist/esm/i18n/locales/es-ES.json +13 -4
  45. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  46. package/dist/esm/i18n/locales/it-IT.json +13 -1
  47. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  48. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  49. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  50. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  51. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  52. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  53. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  54. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  55. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  56. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  57. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  58. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  59. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  60. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  61. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  62. package/dist/esm/index.js +14 -1
  63. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  64. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  65. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  66. package/dist/esm/mixins/index.js +5 -0
  67. package/dist/esm/session/SessionClient.js +40 -4
  68. package/dist/esm/session/accountDialogController.js +288 -41
  69. package/dist/esm/session/identityPin.js +249 -0
  70. package/dist/esm/session/identitySession.js +131 -0
  71. package/dist/esm/session/projectSessionState.js +54 -15
  72. package/dist/esm/session/refresh.js +73 -8
  73. package/dist/esm/utils/commonsDelivery.js +54 -0
  74. package/dist/esm/utils/oauthPkce.js +31 -2
  75. package/dist/types/.tsbuildinfo +1 -1
  76. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  77. package/dist/types/index.d.ts +12 -4
  78. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  79. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  80. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  81. package/dist/types/mixins/index.d.ts +2 -1
  82. package/dist/types/session/SessionClient.d.ts +28 -0
  83. package/dist/types/session/accountDialogController.d.ts +187 -9
  84. package/dist/types/session/identityPin.d.ts +87 -0
  85. package/dist/types/session/identitySession.d.ts +105 -0
  86. package/dist/types/session/projectSessionState.d.ts +21 -19
  87. package/dist/types/session/refresh.d.ts +32 -1
  88. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  89. package/dist/types/utils/oauthPkce.d.ts +21 -1
  90. package/package.json +2 -2
  91. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  92. package/src/boot/sessionColdBoot.ts +183 -37
  93. package/src/i18n/locales/ar-SA.json +13 -1
  94. package/src/i18n/locales/ca-ES.json +13 -1
  95. package/src/i18n/locales/de-DE.json +13 -1
  96. package/src/i18n/locales/en-US.json +13 -4
  97. package/src/i18n/locales/es-ES.json +13 -4
  98. package/src/i18n/locales/fr-FR.json +13 -1
  99. package/src/i18n/locales/it-IT.json +13 -1
  100. package/src/i18n/locales/ja-JP.json +13 -1
  101. package/src/i18n/locales/ko-KR.json +13 -1
  102. package/src/i18n/locales/pt-PT.json +13 -1
  103. package/src/i18n/locales/zh-CN.json +13 -1
  104. package/src/index.ts +49 -0
  105. package/src/mixins/OxyServices.auth.ts +464 -24
  106. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  107. package/src/mixins/OxyServices.notifications.ts +142 -0
  108. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  109. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  110. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  111. package/src/mixins/index.ts +6 -0
  112. package/src/session/SessionClient.ts +57 -4
  113. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  114. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  115. package/src/session/__tests__/identityPin.test.ts +131 -0
  116. package/src/session/__tests__/identitySession.test.ts +194 -0
  117. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  118. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  119. package/src/session/accountDialogController.ts +435 -44
  120. package/src/session/identityPin.ts +310 -0
  121. package/src/session/identitySession.ts +206 -0
  122. package/src/session/projectSessionState.ts +61 -13
  123. package/src/session/refresh.ts +103 -9
  124. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  125. package/src/utils/commonsDelivery.ts +132 -0
  126. package/src/utils/oauthPkce.ts +56 -3
@@ -1,6 +1,8 @@
1
1
  import type { OxyServices } from '../OxyServices';
2
2
  import type { AuthRefreshHandler } from '../HttpService';
3
3
  import type { AuthStateStore } from './authStateStore';
4
+ import type { IdentityPin } from './identityPin';
5
+ import { type IdentityBinding } from './identitySession';
4
6
  /**
5
7
  * Lead time (ms) before access-token expiry at which the proactive scheduler
6
8
  * re-mints. Mirrors `HttpService`'s per-request `TOKEN_REFRESH_LEAD_SECONDS`
@@ -14,9 +16,19 @@ export interface RefreshDeps {
14
16
  /**
15
17
  * Whether to fall back to the native shared-keychain re-mint (arm 2) when the
16
18
  * persisted secret is absent / rejected. Defaults to `isNative()` — web has no
17
- * shared keychain. Exposed for tests.
19
+ * shared keychain. Exposed for tests. IGNORED when {@link identity} is set: an
20
+ * identity-bound client must never adopt the CROSS-APP shared slot, which may
21
+ * hold a different identity than this device's primary key.
18
22
  */
19
23
  allowSharedKeyFallback?: boolean;
24
+ /**
25
+ * Identity-bound (pinned) mode. When present, every re-mint targets the
26
+ * PINNED account — resolved fresh from the pin store on each call, since a
27
+ * re-established session can move it — instead of the device's active
28
+ * account, and arm 2 becomes the PRIMARY-key identity sign-in rather than the
29
+ * shared-keychain one.
30
+ */
31
+ identity?: IdentityBinding;
20
32
  }
21
33
  /**
22
34
  * The outcome of ONE device-secret mint attempt (arm 1). Discriminated so both
@@ -28,6 +40,10 @@ export interface RefreshDeps {
28
40
  * diverged (another tab/device rotated it past the grace window).
29
41
  * - `no-session` — 401 `no_active_session`: the device is known but has no live
30
42
  * session (authoritative signed-out).
43
+ * - `account-not-on-device` — 401 `account_not_on_device` for a PINNED mint: the
44
+ * pinned account is not (or no longer) a live account of this device session.
45
+ * The device secret is FINE — it is the identity binding that went stale, so
46
+ * the caller must re-establish from the local key, never drop the credential.
31
47
  * - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
32
48
  * - `persist-failed` — the mint succeeded (the SERVER rotated the secret) but
33
49
  * the rotated `nextDeviceSecret` could NOT be durably persisted. The token is
@@ -45,6 +61,8 @@ export type DeviceSecretMintOutcome = {
45
61
  status: 'invalid-secret';
46
62
  } | {
47
63
  status: 'no-session';
64
+ } | {
65
+ status: 'account-not-on-device';
48
66
  } | {
49
67
  status: 'transient';
50
68
  } | {
@@ -70,10 +88,18 @@ export type DeviceSecretMintOutcome = {
70
88
  * planting. This function performs NO store mutation on failure — the caller
71
89
  * applies the drop/clear policy (which differs web vs native) from the returned
72
90
  * status.
91
+ *
92
+ * `pin` makes the mint IDENTITY-BOUND: the request carries the pinned
93
+ * `accountId` (so the server mints that account's token without touching
94
+ * `activeAccountId`), and the persisted `sessionId`/`userId` are resolved from
95
+ * the PINNED account entry — never from `state.activeAccountId`, whose drift is
96
+ * exactly what the pin exists to stop.
73
97
  */
74
98
  export declare function refreshDeviceSecretArm(deps: {
75
99
  oxy: OxyServices;
76
100
  store: AuthStateStore;
101
+ /** The identity pin, when this client is identity-bound. */
102
+ pin?: IdentityPin | null;
77
103
  }): Promise<DeviceSecretMintOutcome>;
78
104
  /**
79
105
  * Re-mint the persisted session and return the fresh access token, or `null`
@@ -91,6 +117,11 @@ export declare function refreshDeviceSecretArm(deps: {
91
117
  * recovered `{deviceId, deviceSecret, …}` is PERSISTED so the fast device-secret
92
118
  * lane is repopulated (mirrors the cold boot's `shared-key-signin` step) — an
93
119
  * in-session shared-key recovery must not leave the fast-lane credential empty.
120
+ *
121
+ * IDENTITY-BOUND clients (`deps.identity`) run a different arm 2: the
122
+ * shared-keychain lane is DISABLED (its cross-app slot may hold a different
123
+ * identity) and replaced by {@link establishIdentitySession}, which re-signs a
124
+ * challenge with the PRIMARY local key and rewrites the pin. Arm 1 is pinned.
94
125
  */
95
126
  export declare function refreshPersistedSession(deps: RefreshDeps): Promise<string | null>;
96
127
  /**
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Automatic "Sign in with Oxy" delivery selection (issue #691, Phase 4).
3
+ *
4
+ * The user performs ONE action ("Continue with Oxy"); Oxy — not the user —
5
+ * decides how the approval request reaches their Commons identity. This module
6
+ * is that decision, and nothing else: a single pure function mapping the facts
7
+ * the caller has gathered onto exactly ONE primary route.
8
+ *
9
+ * Deliberate design constraints:
10
+ *
11
+ * - **Pure.** No I/O, no platform sniffing, no clock, no globals. Every input
12
+ * is passed in by the caller, which is what makes the decision exhaustively
13
+ * unit-testable and identical on web, native, and server.
14
+ * - **One route, never a chain.** The result is the PRIMARY route only.
15
+ * Alternatives stay hidden behind a "Having trouble?" affordance and are
16
+ * revealed by the UI only once the primary route fails or is unavailable —
17
+ * the SDK never silently cascades from one delivery surface to the next.
18
+ * - **Fail-safe to QR.** QR is the route that works with no prior knowledge of
19
+ * the device, so every ambiguous or malformed input degrades to it rather
20
+ * than to a route that could leave the user staring at a dead end.
21
+ *
22
+ * Every route drives the SAME `AuthSession`, resolved from the same public
23
+ * `authorizeCode`; the route only decides how that code travels.
24
+ */
25
+ /**
26
+ * The kind of surface the sign-in was initiated from.
27
+ *
28
+ * `'unknown'` is a first-class value, not an error: a caller that cannot
29
+ * confidently classify the surface must say so rather than guess, and an
30
+ * unknown surface never opts into the deep-link route (a custom-scheme
31
+ * navigation that does not resolve is a dead end with no automatic way back).
32
+ */
33
+ export type CommonsDeliveryPlatform = 'mobile' | 'desktop' | 'unknown';
34
+ /**
35
+ * The single primary route chosen for this request.
36
+ *
37
+ * - `'open-commons'` — navigate to the verified Commons app/universal link on
38
+ * THIS device and let the user confirm there.
39
+ * - `'await-push'` — the request was pushed to a known Commons installation;
40
+ * show "Check Commons on your phone" and wait for the authorization.
41
+ * - `'qr'` — render the QR carrying the public `authorizeCode` for the user to
42
+ * scan with Commons on another device.
43
+ */
44
+ export type CommonsDeliveryRoute = 'open-commons' | 'await-push' | 'qr';
45
+ /**
46
+ * The facts the caller must gather before asking for a route. All of them are
47
+ * observations, never decisions — the caller owns the platform detection, the
48
+ * app-link verification, and the delivery round-trip; this module owns only the
49
+ * choice between them.
50
+ */
51
+ export interface CommonsDeliveryFacts {
52
+ /** Which surface the sign-in was initiated from (see {@link CommonsDeliveryPlatform}). */
53
+ platform: CommonsDeliveryPlatform;
54
+ /**
55
+ * `true` only when a VERIFIED Commons app/universal link can be opened on
56
+ * this very device (an installed, link-verified Commons). "The user probably
57
+ * has Commons somewhere" is not this fact — that is what `pushTargets`
58
+ * expresses.
59
+ */
60
+ commonsAvailable: boolean;
61
+ /**
62
+ * How many eligible Commons installations the server said it delivered the
63
+ * request to — the `targets` field of `deliverCommonsSignIn`. Zero is a
64
+ * NORMAL outcome (no capable Commons install is registered for this
65
+ * identity), never an error; it simply means push is not a usable route.
66
+ */
67
+ pushTargets: number;
68
+ }
69
+ /**
70
+ * Choose the ONE primary delivery route for a "Sign in with Oxy" request.
71
+ *
72
+ * Decision order (issue #691, "Automatic delivery selection"):
73
+ *
74
+ * 1. Mobile with a verified Commons link available → open Commons directly.
75
+ * No push is needed when the identity is already reachable on this device.
76
+ * 2. At least one eligible Commons installation was pushed to → await the push.
77
+ * 3. Otherwise → QR.
78
+ *
79
+ * A caller that already knows it is on step 1 does not need to call
80
+ * `deliverCommonsSignIn` at all; every other caller runs the delivery
81
+ * round-trip first and passes its `targets` in here.
82
+ *
83
+ * `pushTargets` is server-derived, so it is validated rather than trusted: a
84
+ * negative, fractional, non-finite, or otherwise malformed count is treated as
85
+ * "no targets" and degrades to QR instead of parking the user on a "check your
86
+ * phone" screen that nothing will ever wake.
87
+ */
88
+ export declare function selectCommonsDelivery(facts: CommonsDeliveryFacts): CommonsDeliveryRoute;
89
+ /**
90
+ * Map a `deliverCommonsSignIn` result onto the `pushTargets` fact for
91
+ * {@link selectCommonsDelivery}. The server reports eligible install *count*
92
+ * separately from whether any push was accepted — a transport failure can leave
93
+ * `delivered: false` with `targets > 0`, which must NOT park the user on
94
+ * "check your phone".
95
+ */
96
+ export declare function pushTargetsFromDelivery(result: {
97
+ delivered: boolean;
98
+ targets: number;
99
+ }): number;
100
+ /**
101
+ * Classify the current runtime surface for delivery selection. Callers that
102
+ * cannot confidently detect the platform should not use this — pass
103
+ * `'unknown'` explicitly instead.
104
+ */
105
+ export declare function commonsDeliveryPlatform(): CommonsDeliveryPlatform;
@@ -47,6 +47,18 @@ export interface BuildOAuthAuthorizeUrlParams {
47
47
  * (no UI when the IdP hub already has a session + grant).
48
48
  */
49
49
  prompt?: 'none' | 'login' | 'consent';
50
+ /**
51
+ * How the IdP should deliver the authorization response. Omitted (the
52
+ * default) means the ordinary top-level redirect back to `redirectUri`.
53
+ *
54
+ * `web_message` asks the IdP to `postMessage` the result to its opener
55
+ * instead, so a popup sign-in never navigates the relying party's tab. It is
56
+ * a REQUEST, not a guarantee: an IdP with no opener still redirects, which is
57
+ * why the popup transport must handle both outcomes. Only the authorization
58
+ * code, the `state`, and a typed OAuth error ever cross that channel — never
59
+ * a token, device secret, or the PKCE verifier, which the opener keeps.
60
+ */
61
+ responseMode?: 'web_message';
50
62
  }
51
63
  /**
52
64
  * Compute the PKCE S256 `code_challenge` for a given verifier:
@@ -81,6 +93,8 @@ export declare function buildOAuthAuthorizeUrl(params: BuildOAuthAuthorizeUrlPar
81
93
  export declare const OXY_OAUTH_STATE_STORAGE_KEY = "oxy_oauth_state";
82
94
  /** `sessionStorage` key for the PKCE `code_verifier` across an authorize redirect. */
83
95
  export declare const OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = "oxy_oauth_code_verifier";
96
+ /** `sessionStorage` key — the exact `redirect_uri` sent on the authorize request. */
97
+ export declare const OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = "oxy.oauth_redirect_uri";
84
98
  /** `sessionStorage` key — at most one silent OAuth attempt per navigation. */
85
99
  export declare const OXY_SILENT_OAUTH_ATTEMPTED_KEY = "oxy.silent_oauth_attempted";
86
100
  /** `sessionStorage` key — blocks further cross-origin auto-restore in this tab. */
@@ -100,6 +114,11 @@ export declare const OXY_OAUTH_RETURN_PATH_STORAGE_KEY = "oxy.oauth_return_path"
100
114
  * out-of-band and restored on return.
101
115
  */
102
116
  export declare function normalizeOAuthRedirectUri(input: string): string;
117
+ /**
118
+ * Collapse `https://app.example/` → `https://app.example` for OAuth binding.
119
+ * Path-qualified redirect URIs are preserved — matches the API token exchange.
120
+ */
121
+ export declare function canonicalizeOAuthRedirectUri(redirectUri: string): string;
103
122
  /**
104
123
  * Remember the page the user was on before a full-page authorize redirect.
105
124
  *
@@ -123,11 +142,12 @@ export declare function persistOAuthReturnPath(path: string): void;
123
142
  */
124
143
  export declare function consumeOAuthReturnPath(): string | null;
125
144
  /** Persist the OAuth handshake for a full-page redirect return (web only). */
126
- export declare function persistOAuthHandshake(state: string, codeVerifier: string): boolean;
145
+ export declare function persistOAuthHandshake(state: string, codeVerifier: string, redirectUri?: string): boolean;
127
146
  /** Read the persisted OAuth handshake, or `null` when absent. */
128
147
  export declare function readOAuthHandshake(): {
129
148
  state: string;
130
149
  codeVerifier: string;
150
+ redirectUri?: string;
131
151
  } | null;
132
152
  /** Drop persisted OAuth handshake keys after a successful or aborted return. */
133
153
  export declare function clearOAuthHandshake(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "12.10.6",
3
+ "version": "12.11.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",
@@ -115,7 +115,7 @@
115
115
  "dependencies": {
116
116
  "@noble/ciphers": "^1.3.0",
117
117
  "@noble/hashes": "^1.8.0",
118
- "@oxyhq/contracts": "^0.17.0",
118
+ "@oxyhq/contracts": "^0.18.0",
119
119
  "@oxyhq/protocol": "^0.1.5",
120
120
  "bip39": "^3.1.0",
121
121
  "buffer": "^6.0.3",
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Cold boot in IDENTITY mode — the identity vault's boot.
3
+ *
4
+ * Invariants under test:
5
+ * - a persisted warm token that belongs to ANOTHER account is never planted;
6
+ * the boot falls through to the PINNED mint instead;
7
+ * - the mint carries the pinned `accountId`, and the resolved session is the
8
+ * pinned account even while the device is switched elsewhere;
9
+ * - `identity-key-signin` (the PRIMARY local key) replaces `shared-key-signin`
10
+ * (the CROSS-APP shared slot) — and the reverse in account mode;
11
+ * - with no verified pin the (unpinned) mint lane is skipped entirely rather
12
+ * than adopting whichever account the device is currently switched to.
13
+ */
14
+ import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
15
+ import type { OxyServices } from '../../OxyServices';
16
+ import type { SessionLoginResponse } from '../../models/session';
17
+ import type { AuthChallenge } from '../../crypto/signatureService';
18
+ import { runSessionColdBoot } from '../sessionColdBoot';
19
+ import type { DeviceSecretMintOutcome } from '../../session/refresh';
20
+ import { createMemoryAuthStateStore, type PersistedAuthState } from '../../session/authStateStore';
21
+ import { createMemoryIdentityPinStore, type IdentityPin } from '../../session/identityPin';
22
+ import type { IdentityBinding } from '../../session/identitySession';
23
+
24
+ const PUBLIC_KEY = `02${'a'.repeat(64)}`;
25
+ const OTHER_PUBLIC_KEY = `03${'b'.repeat(64)}`;
26
+ const PIN: IdentityPin = { publicKey: PUBLIC_KEY, accountId: 'vault-user' };
27
+
28
+ const NATIVE = { isWeb: false, isNative: true };
29
+
30
+ /** A minimal jwt-decode-able token whose `userId` claim is `accountId`. */
31
+ function jwtFor(accountId: string): string {
32
+ const payload = Buffer.from(JSON.stringify({ userId: accountId })).toString('base64url');
33
+ return `h.${payload}.s`;
34
+ }
35
+
36
+ /** Comfortably beyond the 60s refresh lead window. */
37
+ const farFuture = (): string => new Date(Date.now() + 3_600_000).toISOString();
38
+
39
+ function makeMintSingleFlight(): (mint: () => Promise<DeviceSecretMintOutcome>) => Promise<DeviceSecretMintOutcome> {
40
+ let inFlight: Promise<DeviceSecretMintOutcome> | null = null;
41
+ return (mint) => {
42
+ if (!inFlight) {
43
+ inFlight = mint().finally(() => {
44
+ inFlight = null;
45
+ });
46
+ }
47
+ return inFlight;
48
+ };
49
+ }
50
+
51
+ /** The device is switched to `other-user`; the pinned account is still a member. */
52
+ const MINT_WHILE_SWITCHED: DeviceTokenMintResponse = {
53
+ accessToken: jwtFor('vault-user'),
54
+ expiresAt: '2030-01-01T00:00:00.000Z',
55
+ nextDeviceSecret: 'ds-next-secret',
56
+ state: {
57
+ deviceId: 'dev-1',
58
+ accounts: [
59
+ { accountId: 'vault-user', sessionId: 'sess-vault-new', authuser: 0 },
60
+ { accountId: 'other-user', sessionId: 'sess-other', authuser: 1 },
61
+ ],
62
+ activeAccountId: 'other-user',
63
+ revision: 9,
64
+ updatedAt: 1_700_000_000_000,
65
+ },
66
+ };
67
+
68
+ const IDENTITY_SESSION: SessionLoginResponse = {
69
+ sessionId: 'sess-identity',
70
+ deviceId: 'dev-1',
71
+ expiresAt: '2030-01-01T00:00:00.000Z',
72
+ accessToken: 'access-identity',
73
+ deviceSecret: 'ds-identity',
74
+ user: { id: 'vault-user', username: 'vault', name: {} },
75
+ };
76
+
77
+ const SHARED_SESSION: SessionLoginResponse = {
78
+ sessionId: 'sess-shared',
79
+ deviceId: 'dev-1',
80
+ expiresAt: '2030-01-01T00:00:00.000Z',
81
+ accessToken: 'access-shared',
82
+ user: { id: 'shared-user', username: 'shared', name: {} },
83
+ };
84
+
85
+ interface OxyOverrides {
86
+ mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
87
+ signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
88
+ requestChallenge?: OxyServices['requestChallenge'];
89
+ verifyChallenge?: OxyServices['verifyChallenge'];
90
+ }
91
+
92
+ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
93
+ const setTokens = jest.fn();
94
+ const oxy = {
95
+ getBaseURL: () => 'https://api.oxy.so',
96
+ setTokens,
97
+ mintFromDeviceSecret:
98
+ overrides.mintFromDeviceSecret
99
+ ?? (async () => {
100
+ throw new Error('mintFromDeviceSecret not stubbed');
101
+ }),
102
+ signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
103
+ requestChallenge:
104
+ overrides.requestChallenge
105
+ ?? (async () => ({ challenge: 'chal-1', expiresAt: '2030-01-01T00:00:00.000Z' })),
106
+ verifyChallenge: overrides.verifyChallenge ?? (async () => IDENTITY_SESSION),
107
+ httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
108
+ } as unknown as OxyServices;
109
+ return { oxy, setTokens };
110
+ }
111
+
112
+ function signWith(publicKey: string): (challenge: string) => Promise<AuthChallenge> {
113
+ return async (challenge) => ({
114
+ challenge: `sig(${challenge})`,
115
+ publicKey,
116
+ timestamp: 1_700_000_000_000,
117
+ });
118
+ }
119
+
120
+ /** An identity binding, optionally pre-seeded with a pin. */
121
+ async function makeBinding(options: { pin?: IdentityPin; publicKey?: string } = {}): Promise<IdentityBinding> {
122
+ const publicKey = options.publicKey ?? PUBLIC_KEY;
123
+ const pinStore = createMemoryIdentityPinStore();
124
+ if (options.pin) {
125
+ await pinStore.save(options.pin);
126
+ }
127
+ return { pinStore, readPublicKey: async () => publicKey, signChallenge: signWith(publicKey) };
128
+ }
129
+
130
+ async function seedStore(extra: Partial<PersistedAuthState> = {}) {
131
+ const store = createMemoryAuthStateStore();
132
+ await store.save({
133
+ sessionId: 'sess-vault',
134
+ userId: 'vault-user',
135
+ deviceId: 'dev-1',
136
+ deviceSecret: 'ds-secret-orig',
137
+ ...extra,
138
+ });
139
+ return store;
140
+ }
141
+
142
+ describe('runSessionColdBoot — identity mode: warm-token-plant', () => {
143
+ it('plants a warm token that belongs to the pinned account', async () => {
144
+ const store = await seedStore({ accessToken: jwtFor('vault-user'), expiresAt: farFuture() });
145
+ const { oxy, setTokens } = makeOxy();
146
+
147
+ const outcome = await runSessionColdBoot({
148
+ oxy,
149
+ store,
150
+ platform: NATIVE,
151
+ sessionMode: 'identity',
152
+ identity: await makeBinding({ pin: PIN }),
153
+ });
154
+
155
+ expect(outcome).toMatchObject({ kind: 'session', via: 'warm-token-plant' });
156
+ expect(setTokens).toHaveBeenCalledWith(jwtFor('vault-user'));
157
+ });
158
+
159
+ it('does NOT plant a warm token minted for ANOTHER account — falls through to the pinned mint', async () => {
160
+ // The durable drift: an account-mode re-mint rewrote the persisted session to
161
+ // `other-user` while the device was switched. Identity mode must reject it.
162
+ const store = await seedStore({
163
+ sessionId: 'sess-other',
164
+ userId: 'other-user',
165
+ accessToken: jwtFor('other-user'),
166
+ expiresAt: farFuture(),
167
+ });
168
+ const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
169
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
170
+
171
+ const outcome = await runSessionColdBoot({
172
+ oxy,
173
+ store,
174
+ platform: NATIVE,
175
+ sessionMode: 'identity',
176
+ identity: await makeBinding({ pin: PIN }),
177
+ });
178
+
179
+ expect(setTokens).not.toHaveBeenCalledWith(jwtFor('other-user'));
180
+ expect(outcome).toMatchObject({
181
+ kind: 'session',
182
+ via: 'device-secret-mint',
183
+ session: { sessionId: 'sess-vault-new', userId: 'vault-user' },
184
+ });
185
+ expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-1', 'ds-secret-orig', {
186
+ accountId: 'vault-user',
187
+ });
188
+ });
189
+
190
+ it('does NOT plant a warm token whose persisted identity matches but whose CLAIM does not', async () => {
191
+ // Belt-and-braces: `userId` says the pinned account, the token itself does not.
192
+ const store = await seedStore({ accessToken: jwtFor('other-user'), expiresAt: farFuture() });
193
+ const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
194
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
195
+
196
+ const outcome = await runSessionColdBoot({
197
+ oxy,
198
+ store,
199
+ platform: NATIVE,
200
+ sessionMode: 'identity',
201
+ identity: await makeBinding({ pin: PIN }),
202
+ });
203
+
204
+ expect(setTokens).not.toHaveBeenCalledWith(jwtFor('other-user'));
205
+ expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
206
+ });
207
+
208
+ it('plants a warm token in ACCOUNT mode regardless of which account it belongs to (unchanged)', async () => {
209
+ const store = await seedStore({
210
+ sessionId: 'sess-other',
211
+ userId: 'other-user',
212
+ accessToken: jwtFor('other-user'),
213
+ expiresAt: farFuture(),
214
+ });
215
+ const { oxy, setTokens } = makeOxy();
216
+
217
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
218
+
219
+ expect(outcome).toMatchObject({ kind: 'session', via: 'warm-token-plant' });
220
+ expect(setTokens).toHaveBeenCalledWith(jwtFor('other-user'));
221
+ });
222
+ });
223
+
224
+ describe('runSessionColdBoot — identity mode: device-secret-mint', () => {
225
+ it('skips the mint entirely when there is no verified pin', async () => {
226
+ const store = await seedStore();
227
+ const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
228
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
229
+
230
+ const outcome = await runSessionColdBoot({
231
+ oxy,
232
+ store,
233
+ platform: NATIVE,
234
+ sessionMode: 'identity',
235
+ identity: await makeBinding(), // no pin seeded
236
+ });
237
+
238
+ // An unpinned mint would have resolved `other-user` — never acceptable here.
239
+ expect(mintFromDeviceSecret).not.toHaveBeenCalled();
240
+ expect(outcome).toMatchObject({
241
+ kind: 'session',
242
+ via: 'identity-key-signin',
243
+ session: { userId: 'vault-user' },
244
+ });
245
+ });
246
+
247
+ it('keeps the device secret and re-establishes when the pinned account left the device', async () => {
248
+ const store = await seedStore();
249
+ const mintFromDeviceSecret = jest.fn(async () => {
250
+ throw Object.assign(new Error('account_not_on_device'), { status: 401 });
251
+ });
252
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
253
+
254
+ const outcome = await runSessionColdBoot({
255
+ oxy,
256
+ store,
257
+ platform: NATIVE,
258
+ sessionMode: 'identity',
259
+ identity: await makeBinding({ pin: PIN }),
260
+ });
261
+
262
+ expect(outcome).toMatchObject({ kind: 'session', via: 'identity-key-signin' });
263
+ // The healthy credential was never dropped over a stale identity binding
264
+ // (the identity sign-in then overwrote it with the freshly minted one).
265
+ expect(await store.load()).toMatchObject({ deviceSecret: 'ds-identity' });
266
+ });
267
+
268
+ it('clears a pin whose key no longer matches, then re-establishes from the current key', async () => {
269
+ const store = await seedStore();
270
+ const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
271
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
272
+ const pinStore = createMemoryIdentityPinStore();
273
+ await pinStore.save(PIN);
274
+
275
+ const outcome = await runSessionColdBoot({
276
+ oxy,
277
+ store,
278
+ platform: NATIVE,
279
+ sessionMode: 'identity',
280
+ identity: {
281
+ pinStore,
282
+ readPublicKey: async () => OTHER_PUBLIC_KEY,
283
+ signChallenge: signWith(OTHER_PUBLIC_KEY),
284
+ },
285
+ });
286
+
287
+ expect(mintFromDeviceSecret).not.toHaveBeenCalled();
288
+ expect(outcome).toMatchObject({ kind: 'session', via: 'identity-key-signin' });
289
+ expect(await pinStore.load()).toEqual({ publicKey: OTHER_PUBLIC_KEY, accountId: 'vault-user' });
290
+ });
291
+ });
292
+
293
+ describe('runSessionColdBoot — identity-key-signin vs shared-key-signin', () => {
294
+ it('identity mode runs identity-key-signin and NEVER the shared-keychain lane', async () => {
295
+ const store = createMemoryAuthStateStore(); // no mint credential
296
+ const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
297
+ const requestChallenge = jest.fn(async () => ({
298
+ challenge: 'chal-1',
299
+ expiresAt: '2030-01-01T00:00:00.000Z',
300
+ }));
301
+ const verifyChallenge = jest.fn(async () => IDENTITY_SESSION);
302
+ const { oxy } = makeOxy({ signInWithSharedIdentity, requestChallenge, verifyChallenge });
303
+ const binding = await makeBinding();
304
+ const onSession = jest.fn();
305
+
306
+ const outcome = await runSessionColdBoot({
307
+ oxy,
308
+ store,
309
+ platform: NATIVE,
310
+ sessionMode: 'identity',
311
+ identity: binding,
312
+ onSession,
313
+ });
314
+
315
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
316
+ expect(outcome).toMatchObject({ kind: 'session', via: 'identity-key-signin' });
317
+ expect(onSession).toHaveBeenCalledWith(
318
+ expect.objectContaining({
319
+ sessionId: 'sess-identity',
320
+ userId: 'vault-user',
321
+ via: 'identity-key-signin',
322
+ }),
323
+ );
324
+ // Boot-path network calls stay single-attempt.
325
+ expect(requestChallenge).toHaveBeenCalledWith(PUBLIC_KEY, { retry: false });
326
+ expect(verifyChallenge).toHaveBeenCalledWith(
327
+ PUBLIC_KEY,
328
+ 'chal-1',
329
+ 'sig(chal-1)',
330
+ 1_700_000_000_000,
331
+ undefined,
332
+ undefined,
333
+ { retry: false },
334
+ );
335
+ // The pin and the fast-lane credential are both written for the next boot.
336
+ expect(await binding.pinStore.load()).toEqual(PIN);
337
+ expect(await store.load()).toMatchObject({ userId: 'vault-user', deviceSecret: 'ds-identity' });
338
+ });
339
+
340
+ it('account mode runs shared-key-signin and NEVER the identity lane (unchanged)', async () => {
341
+ const store = createMemoryAuthStateStore();
342
+ const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
343
+ const requestChallenge = jest.fn(async () => ({ challenge: 'c', expiresAt: 'e' }));
344
+ const { oxy } = makeOxy({ signInWithSharedIdentity, requestChallenge });
345
+
346
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
347
+
348
+ expect(outcome).toMatchObject({ kind: 'session', via: 'shared-key-signin' });
349
+ expect(signInWithSharedIdentity).toHaveBeenCalledWith({ requestOptions: { retry: false } });
350
+ expect(requestChallenge).not.toHaveBeenCalled();
351
+ });
352
+
353
+ it('is offline-gated like every other network step', async () => {
354
+ const store = createMemoryAuthStateStore();
355
+ const requestChallenge = jest.fn(async () => ({ challenge: 'c', expiresAt: 'e' }));
356
+ const { oxy } = makeOxy({ requestChallenge });
357
+ const onSignedOut = jest.fn();
358
+
359
+ const outcome = await runSessionColdBoot({
360
+ oxy,
361
+ store,
362
+ platform: NATIVE,
363
+ sessionMode: 'identity',
364
+ identity: await makeBinding(),
365
+ isOffline: () => true,
366
+ onSignedOut,
367
+ });
368
+
369
+ expect(requestChallenge).not.toHaveBeenCalled();
370
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
371
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
372
+ });
373
+
374
+ it('ends signed out (never account-mode) when identity mode is requested without a binding', async () => {
375
+ const store = await seedStore({ accessToken: jwtFor('other-user'), expiresAt: farFuture() });
376
+ const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
377
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
378
+ const onSignedOut = jest.fn();
379
+
380
+ const outcome = await runSessionColdBoot({
381
+ oxy,
382
+ store,
383
+ platform: NATIVE,
384
+ sessionMode: 'identity',
385
+ onSignedOut,
386
+ });
387
+
388
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
389
+ expect(onSignedOut).toHaveBeenCalledWith('error');
390
+ expect(setTokens).not.toHaveBeenCalled();
391
+ expect(mintFromDeviceSecret).not.toHaveBeenCalled();
392
+ });
393
+ });