@oxyhq/core 12.10.6 → 12.11.1

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 (139) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +66 -6
  3. package/dist/cjs/boot/sessionColdBoot.js +142 -37
  4. package/dist/cjs/i18n/locales/ar-SA.json +13 -1
  5. package/dist/cjs/i18n/locales/ca-ES.json +13 -1
  6. package/dist/cjs/i18n/locales/de-DE.json +13 -1
  7. package/dist/cjs/i18n/locales/en-US.json +13 -4
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -4
  9. package/dist/cjs/i18n/locales/fr-FR.json +13 -1
  10. package/dist/cjs/i18n/locales/it-IT.json +13 -1
  11. package/dist/cjs/i18n/locales/ja-JP.json +13 -1
  12. package/dist/cjs/i18n/locales/ko-KR.json +13 -1
  13. package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
  14. package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
  15. package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
  16. package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
  17. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
  18. package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
  19. package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
  20. package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
  21. package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
  22. package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
  23. package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
  24. package/dist/cjs/i18n/locales/pt-PT.json +13 -1
  25. package/dist/cjs/i18n/locales/zh-CN.json +13 -1
  26. package/dist/cjs/index.js +31 -5
  27. package/dist/cjs/mixins/OxyServices.auth.js +257 -17
  28. package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
  29. package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
  30. package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
  31. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  32. package/dist/cjs/mixins/index.js +5 -0
  33. package/dist/cjs/session/SessionClient.js +40 -4
  34. package/dist/cjs/session/accountDialogController.js +288 -41
  35. package/dist/cjs/session/identityPin.js +256 -0
  36. package/dist/cjs/session/identitySession.js +135 -0
  37. package/dist/cjs/session/projectSessionState.js +54 -15
  38. package/dist/cjs/session/refresh.js +73 -8
  39. package/dist/cjs/utils/commonsDelivery.js +59 -0
  40. package/dist/cjs/utils/oauthPkce.js +33 -3
  41. package/dist/esm/.tsbuildinfo +1 -1
  42. package/dist/esm/HttpService.js +66 -6
  43. package/dist/esm/boot/sessionColdBoot.js +142 -37
  44. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  45. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  46. package/dist/esm/i18n/locales/de-DE.json +13 -1
  47. package/dist/esm/i18n/locales/en-US.json +13 -4
  48. package/dist/esm/i18n/locales/es-ES.json +13 -4
  49. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  50. package/dist/esm/i18n/locales/it-IT.json +13 -1
  51. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  52. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  53. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  54. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  55. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  56. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  57. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  58. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  59. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  60. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  61. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  62. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  63. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  64. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  65. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  66. package/dist/esm/index.js +14 -1
  67. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  68. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  69. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  70. package/dist/esm/mixins/OxyServices.privacy.js +6 -0
  71. package/dist/esm/mixins/OxyServices.user.js +1 -0
  72. package/dist/esm/mixins/index.js +5 -0
  73. package/dist/esm/session/SessionClient.js +40 -4
  74. package/dist/esm/session/accountDialogController.js +288 -41
  75. package/dist/esm/session/identityPin.js +249 -0
  76. package/dist/esm/session/identitySession.js +131 -0
  77. package/dist/esm/session/projectSessionState.js +54 -15
  78. package/dist/esm/session/refresh.js +73 -8
  79. package/dist/esm/utils/commonsDelivery.js +54 -0
  80. package/dist/esm/utils/oauthPkce.js +31 -2
  81. package/dist/types/.tsbuildinfo +1 -1
  82. package/dist/types/HttpService.d.ts +18 -1
  83. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  84. package/dist/types/index.d.ts +12 -4
  85. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  86. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  87. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  88. package/dist/types/mixins/OxyServices.user.d.ts +2 -0
  89. package/dist/types/mixins/index.d.ts +2 -1
  90. package/dist/types/session/SessionClient.d.ts +28 -0
  91. package/dist/types/session/accountDialogController.d.ts +187 -9
  92. package/dist/types/session/identityPin.d.ts +87 -0
  93. package/dist/types/session/identitySession.d.ts +105 -0
  94. package/dist/types/session/projectSessionState.d.ts +21 -19
  95. package/dist/types/session/refresh.d.ts +32 -1
  96. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  97. package/dist/types/utils/oauthPkce.d.ts +21 -1
  98. package/package.json +3 -3
  99. package/src/HttpService.ts +67 -6
  100. package/src/__tests__/inSessionRefresh.test.ts +67 -0
  101. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  102. package/src/boot/sessionColdBoot.ts +183 -37
  103. package/src/i18n/locales/ar-SA.json +13 -1
  104. package/src/i18n/locales/ca-ES.json +13 -1
  105. package/src/i18n/locales/de-DE.json +13 -1
  106. package/src/i18n/locales/en-US.json +13 -4
  107. package/src/i18n/locales/es-ES.json +13 -4
  108. package/src/i18n/locales/fr-FR.json +13 -1
  109. package/src/i18n/locales/it-IT.json +13 -1
  110. package/src/i18n/locales/ja-JP.json +13 -1
  111. package/src/i18n/locales/ko-KR.json +13 -1
  112. package/src/i18n/locales/pt-PT.json +13 -1
  113. package/src/i18n/locales/zh-CN.json +13 -1
  114. package/src/index.ts +49 -0
  115. package/src/mixins/OxyServices.auth.ts +464 -24
  116. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  117. package/src/mixins/OxyServices.notifications.ts +142 -0
  118. package/src/mixins/OxyServices.privacy.ts +6 -0
  119. package/src/mixins/OxyServices.user.ts +3 -0
  120. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  121. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  122. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  123. package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +2 -0
  124. package/src/mixins/index.ts +6 -0
  125. package/src/session/SessionClient.ts +57 -4
  126. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  127. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  128. package/src/session/__tests__/identityPin.test.ts +131 -0
  129. package/src/session/__tests__/identitySession.test.ts +194 -0
  130. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  131. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  132. package/src/session/accountDialogController.ts +435 -44
  133. package/src/session/identityPin.ts +310 -0
  134. package/src/session/identitySession.ts +206 -0
  135. package/src/session/projectSessionState.ts +61 -13
  136. package/src/session/refresh.ts +103 -9
  137. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  138. package/src/utils/commonsDelivery.ts +132 -0
  139. package/src/utils/oauthPkce.ts +56 -3
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Identity-bound session — pin reconciliation + establishment.
3
+ *
4
+ * This is the SDK half of "an identity vault's authenticated user is whoever
5
+ * owns the local primary key". Two operations, both consumed by the cold boot
6
+ * (`boot/sessionColdBoot.ts`) and the re-mint lane (`session/refresh.ts`):
7
+ *
8
+ * - {@link resolveIdentityPin} — read the persisted `{publicKey, accountId}`
9
+ * pin and reconcile it against the identity currently on this device. A
10
+ * definitive mismatch (the key was replaced, or is gone) CLEARS the pin; an
11
+ * INDETERMINATE read (keychain locked / storage threw) leaves it untouched.
12
+ * - {@link establishIdentitySession} — mint a session from the PRIMARY local
13
+ * key (`getPublicKey` → `requestChallenge` → `signChallenge` →
14
+ * `verifyChallenge`), persist the resulting device credential, and write the
15
+ * pin. This is the identity-mode replacement for the shared-keychain lane,
16
+ * which reads the CROSS-APP shared slot and may therefore hold a different
17
+ * identity than this device's primary.
18
+ *
19
+ * Both take their key/signature functions from an injectable {@link IdentityBinding}
20
+ * (defaulting to `KeyManager` / `SignatureService`) so the lanes above stay
21
+ * testable without a keychain.
22
+ *
23
+ * ESM-safe (no `require()`); no react/react-native/expo imports.
24
+ */
25
+ import type { OxyServices } from '../OxyServices';
26
+ import type { AuthStateStore } from './authStateStore';
27
+ import type { SessionLoginResponse } from '../models/session';
28
+ import { type AuthChallenge } from '../crypto/signatureService';
29
+ import { type IdentityPin, type IdentityPinStore } from './identityPin';
30
+ /** Per-call transport overrides forwarded to the challenge/verify round-trips. */
31
+ export interface IdentityRequestOptions {
32
+ retry?: boolean;
33
+ timeout?: number;
34
+ }
35
+ /**
36
+ * Everything an identity-bound client needs to resolve and re-establish its
37
+ * session. `@oxyhq/services` builds one of these (platform-appropriate pin
38
+ * store) and passes it to the cold boot and the refresh handler.
39
+ */
40
+ export interface IdentityBinding {
41
+ /** Where the `{publicKey, accountId}` pin is persisted. */
42
+ pinStore: IdentityPinStore;
43
+ /**
44
+ * Reads this device's PRIMARY identity public key. Defaults to
45
+ * `KeyManager.getPublicKey()`. NEVER the shared-slot key.
46
+ */
47
+ readPublicKey?: () => Promise<string | null>;
48
+ /**
49
+ * Signs a server challenge with the PRIMARY local private key. Defaults to
50
+ * `SignatureService.signChallenge`.
51
+ */
52
+ signChallenge?: (challenge: string) => Promise<AuthChallenge>;
53
+ /** Optional device labels forwarded to `verifyChallenge`. */
54
+ deviceName?: string;
55
+ deviceFingerprint?: string;
56
+ }
57
+ /** The result of a successful {@link establishIdentitySession}. */
58
+ export interface EstablishedIdentitySession {
59
+ session: SessionLoginResponse;
60
+ /** The pin as written: the local key plus the account the server resolved for it. */
61
+ pin: IdentityPin;
62
+ }
63
+ /**
64
+ * Read the persisted pin and reconcile it against the identity on this device.
65
+ *
66
+ * Returns the pin ONLY when the local primary public key still matches it — that
67
+ * is the single condition under which a client may bind its token and its
68
+ * rendered user to that account.
69
+ *
70
+ * Outcomes:
71
+ * - no pin stored → `null` (nothing to clear).
72
+ * - local key MATCHES → the pin (trusted).
73
+ * - local key read succeeded and differs / is absent → the identity was
74
+ * replaced or lost: CLEAR the pin and return `null`. Keeping it would pin the
75
+ * client to an account it can no longer prove control of.
76
+ * - the local key read THREW (keychain locked, storage unavailable) → return
77
+ * `null` WITHOUT clearing. A read that produced no verdict is never evidence
78
+ * of an identity change (the same rule `KeyManager` applies to its own
79
+ * transient errors); the caller falls through to the identity sign-in lane,
80
+ * which fails closed on a locked keychain rather than adopting a foreign
81
+ * account.
82
+ */
83
+ export declare function resolveIdentityPin(binding: IdentityBinding): Promise<IdentityPin | null>;
84
+ /**
85
+ * Establish a session for the device's PRIMARY identity key and pin it.
86
+ *
87
+ * `requestChallenge` → `signChallenge` → `verifyChallenge`; `verifyChallenge`
88
+ * plants the access token itself. The server resolves the account from the
89
+ * VERIFIED SIGNER, so the returned session is identity-authoritative — it
90
+ * ignores the device's `activeAccountId` entirely.
91
+ *
92
+ * Side effects on success: the durable device credential is persisted (so the
93
+ * next boot can take the fast pinned-mint lane) and the pin is written.
94
+ *
95
+ * Returns `null` — never throws — for the two "nothing to do" cases: no local
96
+ * identity (including every web caller, where `KeyManager` has no key), and a
97
+ * verify that produced no access token. Network/crypto failures propagate so the
98
+ * caller can classify them.
99
+ */
100
+ export declare function establishIdentitySession(args: {
101
+ oxy: OxyServices;
102
+ store: AuthStateStore;
103
+ binding: IdentityBinding;
104
+ requestOptions?: IdentityRequestOptions;
105
+ }): Promise<EstablishedIdentitySession | null>;
@@ -1,17 +1,6 @@
1
1
  import type { DeviceSessionState } from '@oxyhq/contracts';
2
2
  import type { ClientSession } from '../models/session';
3
3
  import type { User } from '../models/interfaces';
4
- /**
5
- * Pure projection helpers: `DeviceSessionState` (the device-scoped
6
- * multi-account session-sync state produced by `SessionClient`) -> the
7
- * shapes `@oxyhq/services` consumers render today
8
- * (`ClientSession[]`, an active session id, an active `User`).
9
- *
10
- * No I/O. The caller fetches profiles via
11
- * `oxyServices.getUsersByIds(accountIdsOf(state))` and builds `usersById`
12
- * from the result before calling `deviceStateToClientSessions` /
13
- * `activeUserOf`.
14
- */
15
4
  /**
16
5
  * Maps every `SessionAccount` in `state.accounts` to a `ClientSession`.
17
6
  *
@@ -23,19 +12,32 @@ import type { User } from '../models/interfaces';
23
12
  * though `ClientSession` only stores `userId` — a session is still
24
13
  * projected for an account whose id is absent from `usersById` (no
25
14
  * placeholder user is fabricated).
15
+ *
16
+ * `isCurrent` marks the PINNED account when one is supplied, so it can never
17
+ * disagree with {@link activeSessionIdOf} / {@link activeUserOf} for the same
18
+ * pin.
26
19
  */
27
- export declare function deviceStateToClientSessions(state: DeviceSessionState, usersById: Map<string, User>): ClientSession[];
20
+ export declare function deviceStateToClientSessions(state: DeviceSessionState, usersById: Map<string, User>, pinnedAccountId?: string | null): ClientSession[];
28
21
  /**
29
- * The active account's `sessionId`, or `null` when there is no state or no
30
- * active account is set.
22
+ * The bound account's `sessionId`, or `null` when there is no state, no bound
23
+ * account, or that account has no session on this device.
24
+ *
25
+ * A pinned account that is ABSENT from `state.accounts` yields `null` — the
26
+ * honest signal that this device no longer carries a session for the pinned
27
+ * identity, which the caller answers by re-establishing the identity session
28
+ * (never by adopting the device's active account).
31
29
  */
32
- export declare function activeSessionIdOf(state: DeviceSessionState | null): string | null;
30
+ export declare function activeSessionIdOf(state: DeviceSessionState | null, pinnedAccountId?: string | null): string | null;
33
31
  /**
34
- * The active account's `User`, resolved from `usersById`. `null` when there
35
- * is no state, no active account is set, or the active account id is absent
36
- * from `usersById`.
32
+ * The bound account's `User`, resolved from `usersById`. `null` when there is no
33
+ * state, no bound account, or the bound account id is absent from `usersById`.
34
+ *
35
+ * A pinned user is resolved from `usersById` alone — deliberately NOT gated on
36
+ * device membership — so a transient device-state gap cannot flicker the
37
+ * identity vault's rendered user. Whether the pinned session still exists on the
38
+ * device is answered by {@link activeSessionIdOf}.
37
39
  */
38
- export declare function activeUserOf(state: DeviceSessionState | null, usersById: Map<string, User>): User | null;
40
+ export declare function activeUserOf(state: DeviceSessionState | null, usersById: Map<string, User>, pinnedAccountId?: string | null): User | null;
39
41
  /**
40
42
  * All account ids in `state`, suitable for an `oxyServices.getUsersByIds(...)`
41
43
  * fetch. `[]` for `null` state.
@@ -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.1",
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",
@@ -157,7 +157,7 @@
157
157
  "expo-crypto": "~56.0.3",
158
158
  "expo-secure-store": "~56.0.4",
159
159
  "express": "^4.22.2",
160
- "express-rate-limit": "^7.5.0",
160
+ "express-rate-limit": "^8.6.0",
161
161
  "regexpu-core": "^6.4.0",
162
162
  "release-it": "^19.0.6",
163
163
  "typescript": "^5.9.2"
@@ -143,12 +143,32 @@ const CSRF_FETCH_RETRY_DELAY_MS = 500;
143
143
 
144
144
  /**
145
145
  * Cooldown (ms) applied after a failed access-token refresh before another
146
- * refresh is attempted. Prevents a refresh storm (and server hammering) when
146
+ * refresh is attempted while the CURRENT token is still valid (a proactive,
147
+ * near-expiry refresh). Prevents a refresh storm (and server hammering) when
147
148
  * the auth refresh handler is failing — every in-flight request that
148
- * hits a 401 would otherwise trigger its own refresh.
149
+ * hits a 401 would otherwise trigger its own refresh. A still-valid token can
150
+ * afford to wait this out; the request keeps carrying it in the meantime.
149
151
  */
150
152
  const TOKEN_REFRESH_COOLDOWN_MS = 15000;
151
153
 
154
+ /**
155
+ * Cooldown (ms) applied after a failed refresh when the CURRENT access token is
156
+ * already past its `exp`. Much shorter than {@link TOKEN_REFRESH_COOLDOWN_MS}:
157
+ * an expired token is UNUSABLE, so the client must re-mint as soon as the mint
158
+ * endpoint is reachable again (e.g. a few seconds after an ECS rolling-deploy
159
+ * blip drains/restarts a task) instead of waiting out the full proactive
160
+ * cooldown while every request forwards or omits a stale bearer → server 401.
161
+ *
162
+ * Still NON-ZERO on purpose: it bounds the request-driven retry rate to at most
163
+ * one attempt per this interval so a PROLONGED outage cannot become a tight
164
+ * network storm. Combined with the process-wide single-flight below (concurrent
165
+ * requests coalesce to one in-flight mint) and the refresh handler's own
166
+ * terminal-state handling (a genuinely revoked session clears its device
167
+ * credential and stops issuing network mints), this recovers a transient blip
168
+ * ~15× faster without weakening the storm guard.
169
+ */
170
+ const EXPIRED_TOKEN_REFRESH_COOLDOWN_MS = 1000;
171
+
152
172
  /**
153
173
  * Lead time (seconds) before access-token expiry at which a preflight refresh
154
174
  * is triggered. A token within this window of `exp` is treated as effectively
@@ -247,7 +267,15 @@ export class HttpService {
247
267
  private logger: SimpleLogger;
248
268
  private config: OxyConfig;
249
269
  private tokenRefreshPromise: Promise<string | null> | null = null;
250
- private tokenRefreshCooldownUntil = 0;
270
+ /**
271
+ * Epoch ms of the last FAILED refresh (0 = none since the last success). The
272
+ * post-failure cooldown is measured from here; its length depends on whether
273
+ * the current token is still valid ({@link TOKEN_REFRESH_COOLDOWN_MS}) or
274
+ * already expired ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}), so an expired
275
+ * token recovers promptly the instant it crosses `exp` — without storing a
276
+ * fixed deadline that could not shrink once the token expired mid-cooldown.
277
+ */
278
+ private lastRefreshFailureAt = 0;
251
279
  private authRefreshHandler: AuthRefreshHandler | null = null;
252
280
  private accessTokenProvider: AccessTokenProvider | null = null;
253
281
  private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
@@ -1058,7 +1086,16 @@ export class HttpService {
1058
1086
  return null;
1059
1087
  }
1060
1088
 
1061
- if (Date.now() < this.tokenRefreshCooldownUntil) {
1089
+ // Post-failure cooldown. A genuinely EXPIRED current token uses a much
1090
+ // shorter cooldown than a still-valid (proactive, near-expiry) one: an
1091
+ // expired token is unusable, so re-mint as soon as the endpoint is reachable
1092
+ // again rather than waiting out the full window while requests carry a stale
1093
+ // bearer. Both cooldowns are measured from the last failure, so the moment a
1094
+ // still-valid token crosses `exp` mid-cooldown the shorter window applies.
1095
+ const cooldownMs = this.isAccessTokenExpired()
1096
+ ? EXPIRED_TOKEN_REFRESH_COOLDOWN_MS
1097
+ : TOKEN_REFRESH_COOLDOWN_MS;
1098
+ if (Date.now() - this.lastRefreshFailureAt < cooldownMs) {
1062
1099
  return null;
1063
1100
  }
1064
1101
 
@@ -1066,19 +1103,22 @@ export class HttpService {
1066
1103
  this.tokenRefreshPromise = this.authRefreshHandler(reason)
1067
1104
  .then((newToken) => {
1068
1105
  if (!newToken) {
1069
- this.tokenRefreshCooldownUntil = Date.now() + TOKEN_REFRESH_COOLDOWN_MS;
1106
+ this.lastRefreshFailureAt = Date.now();
1070
1107
  return null;
1071
1108
  }
1072
1109
  if (this.tokenStore.getAccessToken() !== newToken) {
1073
1110
  this.tokenStore.setTokens(newToken);
1074
1111
  this.notifyTokenChange();
1075
1112
  }
1113
+ // A success clears the failure timestamp so the next refresh is never
1114
+ // throttled by a stale cooldown.
1115
+ this.lastRefreshFailureAt = 0;
1076
1116
  this.logger.debug('Token refreshed via the auth refresh handler');
1077
1117
  return newToken;
1078
1118
  })
1079
1119
  .catch((error) => {
1080
1120
  this.logger.warn('Token refresh failed:', error);
1081
- this.tokenRefreshCooldownUntil = Date.now() + TOKEN_REFRESH_COOLDOWN_MS;
1121
+ this.lastRefreshFailureAt = Date.now();
1082
1122
  return null;
1083
1123
  })
1084
1124
  .finally(() => {
@@ -1089,6 +1129,27 @@ export class HttpService {
1089
1129
  return this.tokenRefreshPromise;
1090
1130
  }
1091
1131
 
1132
+ /**
1133
+ * Whether the CURRENT stored access token is already past its `exp`. Drives
1134
+ * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
1135
+ * a still-valid (near-expiry) token can wait out the full cooldown, but an
1136
+ * expired one must re-mint promptly. Returns `false` for an absent or
1137
+ * opaque/no-`exp` token — no proof it is expired, so keep the conservative
1138
+ * (longer) cooldown and avoid an unnecessary retry loop.
1139
+ */
1140
+ private isAccessTokenExpired(): boolean {
1141
+ const token = this.tokenStore.getAccessToken();
1142
+ if (!token) {
1143
+ return false;
1144
+ }
1145
+ try {
1146
+ const decoded = jwtDecode<JwtPayload>(token);
1147
+ return typeof decoded.exp === 'number' && decoded.exp <= Math.floor(Date.now() / 1000);
1148
+ } catch {
1149
+ return false;
1150
+ }
1151
+ }
1152
+
1092
1153
  /**
1093
1154
  * PROCESS-WIDE single-flight for the rotating device-secret mint
1094
1155
  * (`POST /session/device/token`).
@@ -164,6 +164,73 @@ describe('HttpService in-session refresh handler', () => {
164
164
  // The second call is inside the post-failure cooldown → handler not re-run.
165
165
  expect(handlerCalls).toBe(1);
166
166
  });
167
+
168
+ it('lets an EXPIRED token re-mint promptly instead of waiting out the long proactive cooldown', async () => {
169
+ // Regression: an ECS rolling-deploy blip briefly fails a mint; the 15s
170
+ // proactive cooldown then left the client forwarding/omitting a now-expired
171
+ // bearer for up to 15s after the endpoint recovered (Mention /privacy 401s).
172
+ // An already-expired token must recover on the short cooldown instead.
173
+ globalThis.fetch = async () => jsonResponse({ ok: true });
174
+ const nowSpy = jest.spyOn(Date, 'now');
175
+ const T0 = 1_000_000_000_000;
176
+ nowSpy.mockReturnValue(T0);
177
+
178
+ const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
179
+ // Current token is already 10s past exp (exp is in SECONDS).
180
+ http.setTokens(createJwt({ userId: 'u', exp: Math.floor(T0 / 1000) - 10 }));
181
+
182
+ let handlerCalls = 0;
183
+ http.setAuthRefreshHandler(async () => {
184
+ handlerCalls += 1;
185
+ return null;
186
+ });
187
+
188
+ // First attempt fails → records the failure timestamp.
189
+ await http.refreshAccessToken('preflight');
190
+ expect(handlerCalls).toBe(1);
191
+
192
+ // 500ms later: still inside the SHORT expired cooldown → NOT re-run. This is
193
+ // the storm guard — an expired token does not fully bypass the cooldown.
194
+ nowSpy.mockReturnValue(T0 + 500);
195
+ await http.refreshAccessToken('preflight');
196
+ expect(handlerCalls).toBe(1);
197
+
198
+ // 1.5s later: past the short expired cooldown but WELL inside the 15s
199
+ // proactive cooldown → the expired token re-mints promptly.
200
+ nowSpy.mockReturnValue(T0 + 1500);
201
+ await http.refreshAccessToken('preflight');
202
+ expect(handlerCalls).toBe(2);
203
+
204
+ nowSpy.mockRestore();
205
+ });
206
+
207
+ it('keeps the full 15s cooldown for a still-valid (proactive) near-expiry refresh', async () => {
208
+ globalThis.fetch = async () => jsonResponse({ ok: true });
209
+ const nowSpy = jest.spyOn(Date, 'now');
210
+ const T0 = 1_000_000_000_000;
211
+ nowSpy.mockReturnValue(T0);
212
+
213
+ const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
214
+ // Token is still valid for another hour — a proactive refresh, not expired.
215
+ http.setTokens(createJwt({ userId: 'u', exp: Math.floor(T0 / 1000) + 3600 }));
216
+
217
+ let handlerCalls = 0;
218
+ http.setAuthRefreshHandler(async () => {
219
+ handlerCalls += 1;
220
+ return null;
221
+ });
222
+
223
+ await http.refreshAccessToken('preflight');
224
+ expect(handlerCalls).toBe(1);
225
+
226
+ // 1.5s later: past the short expired cooldown, but the token is NOT expired,
227
+ // so the full proactive cooldown still applies → no re-run (no storm).
228
+ nowSpy.mockReturnValue(T0 + 1500);
229
+ await http.refreshAccessToken('preflight');
230
+ expect(handlerCalls).toBe(1);
231
+
232
+ nowSpy.mockRestore();
233
+ });
167
234
  });
168
235
 
169
236
  describe('OxyServices.getAccessTokenExpiry', () => {