@oxyhq/core 12.10.5 → 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 (130) 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/hubSync.js +1 -1
  33. package/dist/cjs/session/identityPin.js +256 -0
  34. package/dist/cjs/session/identitySession.js +135 -0
  35. package/dist/cjs/session/projectSessionState.js +54 -15
  36. package/dist/cjs/session/refresh.js +73 -8
  37. package/dist/cjs/utils/commonsDelivery.js +59 -0
  38. package/dist/cjs/utils/oauthPkce.js +33 -3
  39. package/dist/esm/.tsbuildinfo +1 -1
  40. package/dist/esm/boot/sessionColdBoot.js +142 -37
  41. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  42. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  43. package/dist/esm/i18n/locales/de-DE.json +13 -1
  44. package/dist/esm/i18n/locales/en-US.json +13 -4
  45. package/dist/esm/i18n/locales/es-ES.json +13 -4
  46. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  47. package/dist/esm/i18n/locales/it-IT.json +13 -1
  48. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  49. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  50. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  51. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  52. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  53. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  54. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  55. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  56. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  57. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  58. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  59. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  60. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  61. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  62. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  63. package/dist/esm/index.js +14 -1
  64. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  65. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  66. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  67. package/dist/esm/mixins/index.js +5 -0
  68. package/dist/esm/session/SessionClient.js +40 -4
  69. package/dist/esm/session/accountDialogController.js +288 -41
  70. package/dist/esm/session/hubSync.js +1 -1
  71. package/dist/esm/session/identityPin.js +249 -0
  72. package/dist/esm/session/identitySession.js +131 -0
  73. package/dist/esm/session/projectSessionState.js +54 -15
  74. package/dist/esm/session/refresh.js +73 -8
  75. package/dist/esm/utils/commonsDelivery.js +54 -0
  76. package/dist/esm/utils/oauthPkce.js +31 -2
  77. package/dist/types/.tsbuildinfo +1 -1
  78. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  79. package/dist/types/index.d.ts +12 -4
  80. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  81. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  82. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  83. package/dist/types/mixins/index.d.ts +2 -1
  84. package/dist/types/session/SessionClient.d.ts +28 -0
  85. package/dist/types/session/accountDialogController.d.ts +187 -9
  86. package/dist/types/session/identityPin.d.ts +87 -0
  87. package/dist/types/session/identitySession.d.ts +105 -0
  88. package/dist/types/session/projectSessionState.d.ts +21 -19
  89. package/dist/types/session/refresh.d.ts +32 -1
  90. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  91. package/dist/types/utils/oauthPkce.d.ts +21 -1
  92. package/package.json +2 -2
  93. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  94. package/src/boot/sessionColdBoot.ts +183 -37
  95. package/src/i18n/locales/ar-SA.json +13 -1
  96. package/src/i18n/locales/ca-ES.json +13 -1
  97. package/src/i18n/locales/de-DE.json +13 -1
  98. package/src/i18n/locales/en-US.json +13 -4
  99. package/src/i18n/locales/es-ES.json +13 -4
  100. package/src/i18n/locales/fr-FR.json +13 -1
  101. package/src/i18n/locales/it-IT.json +13 -1
  102. package/src/i18n/locales/ja-JP.json +13 -1
  103. package/src/i18n/locales/ko-KR.json +13 -1
  104. package/src/i18n/locales/pt-PT.json +13 -1
  105. package/src/i18n/locales/zh-CN.json +13 -1
  106. package/src/index.ts +49 -0
  107. package/src/mixins/OxyServices.auth.ts +464 -24
  108. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  109. package/src/mixins/OxyServices.notifications.ts +142 -0
  110. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  111. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  112. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  113. package/src/mixins/index.ts +6 -0
  114. package/src/session/SessionClient.ts +57 -4
  115. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  116. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  117. package/src/session/__tests__/hubSync.test.ts +51 -0
  118. package/src/session/__tests__/identityPin.test.ts +131 -0
  119. package/src/session/__tests__/identitySession.test.ts +194 -0
  120. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  121. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  122. package/src/session/accountDialogController.ts +435 -44
  123. package/src/session/hubSync.ts +1 -1
  124. package/src/session/identityPin.ts +310 -0
  125. package/src/session/identitySession.ts +206 -0
  126. package/src/session/projectSessionState.ts +61 -13
  127. package/src/session/refresh.ts +103 -9
  128. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  129. package/src/utils/commonsDelivery.ts +132 -0
  130. package/src/utils/oauthPkce.ts +56 -3
@@ -24,6 +24,7 @@ import { OxyServicesUtilityMixin } from './OxyServices.utility';
24
24
  import { OxyServicesFeaturesMixin } from './OxyServices.features';
25
25
  import { OxyServicesTopicsMixin } from './OxyServices.topics';
26
26
  import { OxyServicesContactsMixin } from './OxyServices.contacts';
27
+ import { OxyServicesNotificationsMixin } from './OxyServices.notifications';
27
28
  import { OxyServicesAppDataMixin } from './OxyServices.appData';
28
29
  import { OxyServicesCivicMixin } from './OxyServices.civic';
29
30
  import { OxyServicesNodesMixin } from './OxyServices.nodes';
@@ -39,7 +40,7 @@ import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
39
40
  * If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
40
41
  * are visible without a cast.
41
42
  */
42
- type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
43
+ type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNotificationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
43
44
  /**
44
45
  * Constructor type for the fully composed mixin pipeline. Each mixin returns
45
46
  * a new constructor that augments its input; reducing across the pipeline
@@ -66,6 +66,22 @@ export interface SessionClientOptions {
66
66
  * absent it falls back to `getSocketIO()`.
67
67
  */
68
68
  socketFactory?: SocketIOFactory;
69
+ /**
70
+ * The PINNED account id for an IDENTITY-BOUND client (the identity vault),
71
+ * or `null` for every ordinary account-mode client. Read as a function because
72
+ * the pin is resolved asynchronously at boot and can move when the identity
73
+ * session is re-established.
74
+ *
75
+ * While pinned this client tracks device state TRUTHFULLY — other apps'
76
+ * accounts and the device's real `activeAccountId` stay visible in `getState()`
77
+ * — but it NEVER re-binds its bearer to that active account: no `activeToken`
78
+ * is planted for a non-pinned account (from a sync response or a pushed
79
+ * `session_state`), the `TokenTransport` (whose entire job is converging on
80
+ * `activeAccountId`) is bypassed, and a push that switches the device does not
81
+ * trigger a token re-fetch. The pinned token's lifecycle belongs solely to the
82
+ * cold boot / re-mint lane, which mints it with an explicit `accountId`.
83
+ */
84
+ getPinnedAccountId?: () => string | null;
69
85
  }
70
86
  type StateListener = (state: DeviceSessionState | null) => void;
71
87
  export declare class SessionClient {
@@ -84,6 +100,13 @@ export declare class SessionClient {
84
100
  private readonly boundServerEvents;
85
101
  constructor(host: SessionClientHost, options?: SessionClientOptions);
86
102
  getState(): DeviceSessionState | null;
103
+ /**
104
+ * The account this client's bearer is pinned to, or `null` when it follows the
105
+ * device's active account (the default). Resolvers are expected to be a plain
106
+ * synchronous read of already-resolved state (see
107
+ * {@link SessionClientOptions.getPinnedAccountId}).
108
+ */
109
+ private pinnedAccountId;
87
110
  subscribe(listener: StateListener): () => void;
88
111
  /**
89
112
  * Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
@@ -138,6 +161,11 @@ export declare class SessionClient {
138
161
  * sign-in. This adds, then switches to the target so the UI lands on the
139
162
  * account the user just authenticated.
140
163
  *
164
+ * An identity-bound (pinned) client only ADDS: its own session is minted for
165
+ * the pinned account explicitly, so switching the device would gratuitously
166
+ * re-elect the active account under every OTHER app on this device — a
167
+ * mutation a pinned client must never make.
168
+ *
141
169
  * @param accountId - The signed-in account id (e.g. `session.user.id`). When
142
170
  * omitted, falls back to the host's current-account ref. If neither
143
171
  * resolves, the add still applies and no switch is performed.
@@ -21,6 +21,17 @@
21
21
  * - the "Sign in with Oxy" device flow (same-device shared-keychain via
22
22
  * `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
23
23
  * via `startCommonsSignIn` → poll → `claimSessionByToken`);
24
+ * - AUTOMATIC delivery selection for that flow (issue #691): the user presses
25
+ * ONE primary action and the controller — not the user — picks how the
26
+ * request reaches their Commons identity, by gathering the facts
27
+ * (`platform`, `commonsAvailability`, and the `targets` a bearer-authorized
28
+ * `deliverCommonsSignIn` reached) and handing them to the pure
29
+ * `selectCommonsDelivery`. Exactly one route is primary; alternatives are
30
+ * never chained behind it, they are state (`signIn.route`,
31
+ * `signIn.routeFailed`) the UI reveals on its own terms;
32
+ * - honest, non-sensitive PROGRESS for that flow (`signIn.progress`), derived
33
+ * only from real signals — the chosen route, `pushSentAt`, `openedAt`,
34
+ * `authorized`, and the commit — never from an optimistic timeline;
24
35
  * - `commonsAvailability` — whether Commons is installed on this device
25
36
  * (native only, via the injected `canOpenApp` probe), so the QR view can
26
37
  * offer a "Get Commons" fallback instead of a same-device dead end.
@@ -48,6 +59,7 @@ import type { SessionLoginResponse, MinimalUserData } from '../models/session';
48
59
  import type { SessionClient } from './SessionClient';
49
60
  import type { SocketIOFactory } from './socketLoader';
50
61
  import { type SwitchableAccount } from './accountProjection';
62
+ import { type CommonsDeliveryPlatform, type CommonsDeliveryRoute } from '../utils/commonsDelivery';
51
63
  /** The dialog's top-level view. */
52
64
  export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add' | 'signup';
53
65
  /**
@@ -62,8 +74,40 @@ export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add' | 'signup';
62
74
  * (native only). A probe error is treated as `'unavailable'` (fail-closed).
63
75
  */
64
76
  export type CommonsAvailability = 'unknown' | 'checking' | 'available' | 'unavailable';
65
- /** Lifecycle phase of the "Sign in with Oxy" device flow. */
66
- export type SignInFlowPhase = 'idle' | 'starting' | 'waiting' | 'authorized' | 'error';
77
+ /**
78
+ * Lifecycle phase of the "Sign in with Oxy" device flow — the RESOURCE state
79
+ * (is there a live request, a claim in flight, a terminal outcome), as opposed
80
+ * to {@link SignInProgress}, which is what the user is told.
81
+ *
82
+ * `'completed'` is terminal-but-successful: the session was claimed and
83
+ * committed, nothing is in flight, and the surface may show "Identity
84
+ * confirmed" before it goes away. It is cleared back to `'idle'` the moment the
85
+ * dialog moves to another view (a new intention), so a later sign-in entry can
86
+ * never inherit the previous flow's terminal state.
87
+ */
88
+ export type SignInFlowPhase = 'idle' | 'starting' | 'waiting' | 'authorized' | 'completed' | 'error';
89
+ /**
90
+ * Ordered, non-sensitive progress of the active request — the ONE thing the
91
+ * sign-in surface renders its status line from (issue #691, Phase 5).
92
+ *
93
+ * Every step is DERIVED from a real fact the controller observed; there is no
94
+ * optimistic sequence and no timer that advances it. The mapping the UI is
95
+ * expected to render (copy lives in the UI layer, never here):
96
+ *
97
+ * | value | shown as | advanced by |
98
+ * |------------------------|--------------------------------|-------------|
99
+ * | `idle` | (nothing) | no live request, or a terminal error |
100
+ * | `preparing` | "Preparing request" | the request is being created / its route is still being resolved |
101
+ * | `awaiting-approval` | route-specific waiting copy | the primary route is known (`qr` → the QR itself, `open-commons` → "Continue in Commons") |
102
+ * | `delivered-to-commons` | "Check Commons on your phone" | the server confirmed a push to ≥1 known Commons install (`await-push`, or `pushSentAt`) |
103
+ * | `opened-in-commons` | "Opened in Commons" | the approver reported `openedAt` |
104
+ * | `confirming-identity` | "Confirming identity" | the request reported `authorized`; the claim/commit is running |
105
+ * | `identity-confirmed` | "Identity confirmed" | the session was claimed and committed |
106
+ *
107
+ * `opened-in-commons` and `delivered-to-commons` are PROGRESS only — neither is
108
+ * evidence of an approval. Only `authorized` moves the flow forward.
109
+ */
110
+ export type SignInProgress = 'idle' | 'preparing' | 'awaiting-approval' | 'delivered-to-commons' | 'opened-in-commons' | 'confirming-identity' | 'identity-confirmed';
67
111
  /**
68
112
  * Minimal structural handle over a popup `Window` — just enough for the
69
113
  * cross-origin passkey hub flow ({@link AccountDialogController.startPasskeyHubSignIn}):
@@ -79,7 +123,14 @@ export interface PopupWindowHandle {
79
123
  href: string;
80
124
  };
81
125
  }
82
- /** State of the "Sign in with Oxy" (shared-key / QR) device flow. */
126
+ /**
127
+ * State of the "Sign in with Oxy" (shared-key / QR) device flow.
128
+ *
129
+ * Everything here is safe to render. The flow's SECRET credential (the
130
+ * device-flow `sessionToken`) is deliberately absent — it never leaves the
131
+ * controller's private field, so no surface, log, or serialized snapshot can
132
+ * leak it.
133
+ */
83
134
  export interface SignInFlowState {
84
135
  phase: SignInFlowPhase;
85
136
  /**
@@ -96,6 +147,46 @@ export interface SignInFlowState {
96
147
  expiresAt: number | null;
97
148
  /** Human-readable error for the retry UI, or `null`. */
98
149
  error: string | null;
150
+ /**
151
+ * The ONE primary delivery route the controller chose for this request
152
+ * ({@link selectCommonsDelivery}), or `null` while it is still being resolved.
153
+ *
154
+ * There is no chain: exactly one route is primary, and the UI renders exactly
155
+ * one action for it. Alternatives stay behind a "Having trouble?" affordance
156
+ * that the UI reveals on its own terms — see {@link routeFailed}.
157
+ */
158
+ route: CommonsDeliveryRoute | null;
159
+ /**
160
+ * `true` when the chosen primary route could NOT be carried out on this
161
+ * device — today only `'open-commons'` can fail this way (no URL opener was
162
+ * injected, or opening the verified Commons link threw). It is the signal the
163
+ * UI needs to reveal its alternatives; the controller never cascades to
164
+ * another route by itself.
165
+ *
166
+ * A push that reached zero installations is NOT a failure: no capable Commons
167
+ * install is a normal outcome that simply resolves the primary route to
168
+ * `'qr'`.
169
+ */
170
+ routeFailed: boolean;
171
+ /**
172
+ * Server-reported ISO-8601 timestamp of when the request was pushed to a
173
+ * known Commons installation, or `null`. Progress only — never evidence of an
174
+ * approval. Monotone: once observed it is never cleared by a later, emptier
175
+ * poll response.
176
+ */
177
+ pushSentAt: string | null;
178
+ /**
179
+ * Server-reported ISO-8601 timestamp of when the approval route was OPENED in
180
+ * Commons, or `null`. Progress only — never evidence of an approval. Monotone,
181
+ * like {@link pushSentAt}.
182
+ */
183
+ openedAt: string | null;
184
+ /**
185
+ * The derived, ordered progress the surface renders. Always computed from the
186
+ * facts above by {@link deriveSignInProgress} — never assigned directly, so it
187
+ * cannot drift from them or run ahead of a real signal.
188
+ */
189
+ progress: SignInProgress;
99
190
  }
100
191
  /** Immutable snapshot consumed by `useSyncExternalStore`. */
101
192
  export interface AccountDialogSnapshot {
@@ -208,6 +299,18 @@ export interface AccountDialogControllerOptions {
208
299
  * `https://auth.${CENTRAL_IDP_APEX}`). Overridable for local/staging testing.
209
300
  */
210
301
  hubBaseUrl?: string;
302
+ /**
303
+ * Which surface the sign-in is initiated from — a FACT supplied by the
304
+ * consumer, because only the consumer can classify its own environment
305
+ * (native → `'mobile'`; web → `'mobile'` for a mobile browser, `'desktop'`
306
+ * otherwise). Headless core never sniffs a user agent or a platform global.
307
+ *
308
+ * Feeds {@link selectCommonsDelivery} verbatim. Defaults to `'unknown'`,
309
+ * which is a first-class value there: an unclassified surface never opts into
310
+ * the deep-link route, because a custom-scheme navigation that does not
311
+ * resolve is a dead end with no automatic way back.
312
+ */
313
+ platform?: CommonsDeliveryPlatform;
211
314
  }
212
315
  type SnapshotListener = (snapshot: AccountDialogSnapshot) => void;
213
316
  export declare class AccountDialogController {
@@ -224,6 +327,7 @@ export declare class AccountDialogController {
224
327
  private readonly socketFactory?;
225
328
  private readonly openPopup?;
226
329
  private readonly hubBaseUrl;
330
+ private readonly platform;
227
331
  private readonly listeners;
228
332
  private view;
229
333
  private graph;
@@ -375,8 +479,57 @@ export declare class AccountDialogController {
375
479
  * same `startCommonsSignIn` → poll/socket wiring either way. Returns the
376
480
  * handle on success (already reflected in `signIn`), or `null` on failure
377
481
  * (already set as `signIn.error`).
482
+ *
483
+ * @param opts.deliver - Whether to run automatic Commons delivery selection
484
+ * ({@link resolveDeliveryRoute}). `true` for the normal one-primary-action
485
+ * entry; `false` when the caller already owns the primary surface (the
486
+ * passkey hub popup), where the request's Commons route is simply the QR.
378
487
  */
379
488
  private startDeviceFlowSession;
489
+ /**
490
+ * Choose and carry out the ONE primary delivery route for the active request
491
+ * (issue #691, "Automatic delivery selection").
492
+ *
493
+ * The controller gathers the facts — is a verified Commons link openable on
494
+ * THIS device, and how many known Commons installations did Oxy actually push
495
+ * to — and hands them to the pure {@link selectCommonsDelivery}. The selector
496
+ * owns the decision; this method owns only the observations and the single
497
+ * action the chosen route implies. It never cascades: a route that yields
498
+ * nothing resolves to QR *before* a route is chosen, never after.
499
+ */
500
+ private resolveDeliveryRoute;
501
+ /**
502
+ * Whether `sessionToken` is still THE request this surface is waiting on.
503
+ *
504
+ * Guards every step of the asynchronous route resolution: a cancelled,
505
+ * superseded, failed, or already-approved flow must neither open Commons nor
506
+ * mutate the surface — an approval that lands mid-resolution would otherwise
507
+ * be followed by a pointless app switch.
508
+ */
509
+ private isAwaitingApproval;
510
+ /**
511
+ * Ask Oxy to deliver the pending request to the identity's known Commons
512
+ * installations, returning how many it reached (`0` when delivery is not
513
+ * applicable, not permitted, or reached nobody).
514
+ *
515
+ * Two hard rules, both from the issue:
516
+ * - **Never push from an unauthenticated surface.** `deliverCommonsSignIn`
517
+ * is bearer-required precisely because a request that merely carries a
518
+ * typed-in username must never be able to ring somebody's phone. Without a
519
+ * planted bearer the call is not made AT ALL — not made-and-failed.
520
+ * - **Zero targets and a failed delivery are the same normal outcome.** Both
521
+ * return `0`, which resolves the primary route to QR silently. Neither is
522
+ * surfaced as an error: there is nothing the user did wrong and nothing for
523
+ * them to fix.
524
+ */
525
+ private deliverToKnownCommons;
526
+ /**
527
+ * Open the verified Commons link for the `'open-commons'` route. Returns
528
+ * whether the navigation was actually handed off, so a missing opener or a
529
+ * throwing one becomes an observable `routeFailed` instead of a silent dead
530
+ * end the user stares at.
531
+ */
532
+ private openCommonsLink;
380
533
  /**
381
534
  * Poll {@link PopupWindowHandle.closed} so a user who dismisses the hub
382
535
  * popup without completing sign-in gets prompt feedback (there is no DOM
@@ -402,14 +555,22 @@ export declare class AccountDialogController {
402
555
  */
403
556
  private resolveCommonsAvailability;
404
557
  /**
405
- * When Commons is confirmed installed, deep-link straight into its approve
406
- * screen via the injected `openUrl` with the same `oxycommons://approve?...`
407
- * payload the QR encodes. Best-effort and non-blocking — the QR/polling
408
- * fallback stays live regardless of the outcome here.
558
+ * Tear down the active sign-in device flow (timers + socket + popup + token),
559
+ * WITHDRAW the request server-side, and reset to idle.
560
+ *
561
+ * Cancellation has to converge in both directions: the surface closing must
562
+ * cancel the request, not just stop listening to it. Without the withdrawal a
563
+ * dismissed QR would stay approvable until it expired, so a later scan of a
564
+ * stale code could authorize a session nobody is waiting for.
409
565
  */
410
- private deepLinkIntoCommonsIfAvailable;
411
- /** Tear down the active sign-in device flow (timers + socket + popup + token) and reset to idle. */
412
566
  cancelSignIn(): void;
567
+ /**
568
+ * Best-effort server-side withdrawal of a request this surface abandoned
569
+ * (`POST /auth/session/deny/:authorizeCode`). Fire-and-forget by design: the
570
+ * local teardown already happened, and a race with an approval that just
571
+ * landed legitimately rejects here — neither outcome is worth surfacing.
572
+ */
573
+ private withdrawRequest;
413
574
  private scheduleNextPoll;
414
575
  /**
415
576
  * Run one status check + (on approval) claim. Triggered by the fallback timer
@@ -419,6 +580,15 @@ export declare class AccountDialogController {
419
580
  * re-checks the authoritative status via `pollCommonsSignIn`.
420
581
  */
421
582
  private pollOnce;
583
+ /**
584
+ * Record server-reported delivery progress on the active flow.
585
+ *
586
+ * Monotone and additive: a timestamp is only ever adopted, never replaced or
587
+ * cleared, so an older API build (or a partial payload) that omits a field can
588
+ * at most fail to advance the surface — it can never walk it backwards.
589
+ * Emits only on a real change, so a steady poll does not churn the snapshot.
590
+ */
591
+ private recordDeliveryProgress;
422
592
  private claimAndComplete;
423
593
  /**
424
594
  * Commit an authorized session, notify, and return to the account list. Shared
@@ -454,7 +624,15 @@ export declare class AccountDialogController {
454
624
  private openAuthSessionSocket;
455
625
  /** Tear down the `/auth-session` socket, if any. Idempotent. */
456
626
  private closeAuthSessionSocket;
627
+ /**
628
+ * Replace the device-flow state from its FACTS, re-deriving `progress`. The
629
+ * only writer of `this.signIn` besides the two terminal constants — which is
630
+ * what keeps `progress` impossible to set by hand, and therefore impossible
631
+ * to advance without a fact behind it.
632
+ */
457
633
  private setSignIn;
634
+ /** Update a subset of the device-flow facts, re-deriving `progress`. */
635
+ private patchSignIn;
458
636
  private computeSnapshot;
459
637
  /** Recompute the snapshot and notify subscribers. */
460
638
  private emit;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Identity pin — the durable binding between THIS device's PRIMARY identity key
3
+ * and the account that key authenticates as.
4
+ *
5
+ * An identity-bound client (Commons, the identity vault) must always act as the
6
+ * owner of the local signing key — permanently. The `DeviceSession` it shares
7
+ * with every other Oxy app on the same device tracks a MUTABLE `activeAccountId`
8
+ * that any sibling app can switch; following it silently changes both the user
9
+ * such a client renders and the bearer it sends. The pin is the client-side
10
+ * record that breaks that coupling: `{ publicKey, accountId }`, written when an
11
+ * identity-mode session is established (the only moment both facts are known
12
+ * first-hand), read on every boot, and reconciled against the live
13
+ * `KeyManager.getPublicKey()` before it is trusted (see
14
+ * `resolveIdentityPin` in `identitySession.ts`).
15
+ *
16
+ * It holds NO secret — a public key and an account id, both already known to the
17
+ * server — so it lives in the same per-origin storage as {@link PersistedAuthState}
18
+ * (`authStateStore.ts`) rather than the keychain, and mirrors that module's
19
+ * store shape: `load / save / clear`, an in-memory mirror, a read-back-verified
20
+ * durable write, and graceful degradation when storage is unavailable.
21
+ *
22
+ * ESM-safe (no `require()`); no react/react-native/expo imports.
23
+ */
24
+ import type { NativeKeyValueStorage } from './authStateStore';
25
+ /**
26
+ * The persisted identity binding.
27
+ *
28
+ * `publicKey` is the device's PRIMARY identity public key (lower-case hex, as
29
+ * `KeyManager` stores it) — never the cross-app shared-slot key, which may hold
30
+ * a different identity. `accountId` is the account the server resolved for that
31
+ * key when the session was established.
32
+ */
33
+ export interface IdentityPin {
34
+ publicKey: string;
35
+ accountId: string;
36
+ }
37
+ /**
38
+ * The storage seam for {@link IdentityPin}. Async throughout so one interface
39
+ * fits both synchronous web `localStorage` and asynchronous native
40
+ * SecureStore/AsyncStorage.
41
+ */
42
+ export interface IdentityPinStore {
43
+ load(): Promise<IdentityPin | null>;
44
+ /**
45
+ * Persist the pin and report whether it durably landed.
46
+ *
47
+ * `true` means the value is retained consistent with this store's durability
48
+ * guarantee (a durable backing whose read-back matched, or a degraded/in-memory
49
+ * store that held it in memory). `false` means a DURABLE backing was expected
50
+ * but the write did not land — the in-memory mirror keeps this process pinned,
51
+ * but the next cold boot will have to re-establish the identity session (which
52
+ * rewrites the pin) instead of taking the fast pinned-mint lane.
53
+ */
54
+ save(pin: IdentityPin): Promise<boolean>;
55
+ clear(): Promise<void>;
56
+ }
57
+ /**
58
+ * Versioned storage key. Deliberately distinct from the `oxy.auth.*` keys so a
59
+ * session sign-out (which clears the auth blob) and an identity change (which
60
+ * clears the pin) can never take each other down.
61
+ */
62
+ export declare const IDENTITY_PIN_STORAGE_KEY = "oxy.identity.pin.v1";
63
+ /**
64
+ * Whether `pin` still describes the identity currently on this device.
65
+ *
66
+ * `publicKey` comparison is case-insensitive (hex). A `null` local key is a
67
+ * definitive "no identity here" and therefore never a match — the caller clears
68
+ * the pin rather than binding to an account whose key is gone.
69
+ */
70
+ export declare function identityPinMatches(pin: IdentityPin | null, localPublicKey: string | null): boolean;
71
+ /**
72
+ * A process-lifetime, in-memory {@link IdentityPinStore}. Used directly for
73
+ * tests/SSR and as the degraded fallback of the web store.
74
+ */
75
+ export declare function createMemoryIdentityPinStore(): IdentityPinStore;
76
+ /**
77
+ * A `localStorage`-backed {@link IdentityPinStore}. Degrades to an in-memory
78
+ * store when `localStorage` is unreachable, and keeps an in-memory mirror so a
79
+ * failed persist still pins THIS page's lifetime.
80
+ */
81
+ export declare function createWebIdentityPinStore(): IdentityPinStore;
82
+ /**
83
+ * A native {@link IdentityPinStore} over an injected async key/value store —
84
+ * the same seam `createNativeAuthStateStore` uses, so `@oxyhq/core` never
85
+ * imports `expo-secure-store`.
86
+ */
87
+ export declare function createNativeIdentityPinStore(storage: NativeKeyValueStorage): IdentityPinStore;
@@ -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.