@oxy.so/core 1.2.1 → 1.3.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 (100) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/identityMove.js +123 -0
  3. package/dist/cjs/crypto/keyManager.js +60 -21
  4. package/dist/cjs/crypto/webIdentityCarrier.js +262 -0
  5. package/dist/cjs/i18n/locales/ar-SA.json +16 -13
  6. package/dist/cjs/i18n/locales/ca-ES.json +16 -13
  7. package/dist/cjs/i18n/locales/de-DE.json +16 -13
  8. package/dist/cjs/i18n/locales/en-US.json +16 -17
  9. package/dist/cjs/i18n/locales/es-ES.json +16 -17
  10. package/dist/cjs/i18n/locales/fr-FR.json +16 -13
  11. package/dist/cjs/i18n/locales/it-IT.json +16 -13
  12. package/dist/cjs/i18n/locales/ja-JP.json +16 -13
  13. package/dist/cjs/i18n/locales/ko-KR.json +16 -13
  14. package/dist/cjs/i18n/locales/locales/ar-SA.json +16 -13
  15. package/dist/cjs/i18n/locales/locales/ca-ES.json +16 -13
  16. package/dist/cjs/i18n/locales/locales/de-DE.json +16 -13
  17. package/dist/cjs/i18n/locales/locales/en-US.json +16 -17
  18. package/dist/cjs/i18n/locales/locales/es-ES.json +16 -17
  19. package/dist/cjs/i18n/locales/locales/fr-FR.json +16 -13
  20. package/dist/cjs/i18n/locales/locales/it-IT.json +16 -13
  21. package/dist/cjs/i18n/locales/locales/ja-JP.json +16 -13
  22. package/dist/cjs/i18n/locales/locales/ko-KR.json +16 -13
  23. package/dist/cjs/i18n/locales/locales/pt-PT.json +16 -13
  24. package/dist/cjs/i18n/locales/locales/zh-CN.json +16 -13
  25. package/dist/cjs/i18n/locales/pt-PT.json +16 -13
  26. package/dist/cjs/i18n/locales/zh-CN.json +16 -13
  27. package/dist/cjs/index.js +38 -4
  28. package/dist/cjs/mixins/OxyServices.auth.js +14 -4
  29. package/dist/cjs/mixins/OxyServices.identity.js +14 -0
  30. package/dist/cjs/session/accountDialogController.js +333 -142
  31. package/dist/cjs/utils/authWebUrl.js +8 -1
  32. package/dist/esm/.tsbuildinfo +1 -1
  33. package/dist/esm/crypto/identityMove.js +110 -0
  34. package/dist/esm/crypto/keyManager.js +60 -21
  35. package/dist/esm/crypto/webIdentityCarrier.js +243 -0
  36. package/dist/esm/i18n/locales/ar-SA.json +16 -13
  37. package/dist/esm/i18n/locales/ca-ES.json +16 -13
  38. package/dist/esm/i18n/locales/de-DE.json +16 -13
  39. package/dist/esm/i18n/locales/en-US.json +16 -17
  40. package/dist/esm/i18n/locales/es-ES.json +16 -17
  41. package/dist/esm/i18n/locales/fr-FR.json +16 -13
  42. package/dist/esm/i18n/locales/it-IT.json +16 -13
  43. package/dist/esm/i18n/locales/ja-JP.json +16 -13
  44. package/dist/esm/i18n/locales/ko-KR.json +16 -13
  45. package/dist/esm/i18n/locales/locales/ar-SA.json +16 -13
  46. package/dist/esm/i18n/locales/locales/ca-ES.json +16 -13
  47. package/dist/esm/i18n/locales/locales/de-DE.json +16 -13
  48. package/dist/esm/i18n/locales/locales/en-US.json +16 -17
  49. package/dist/esm/i18n/locales/locales/es-ES.json +16 -17
  50. package/dist/esm/i18n/locales/locales/fr-FR.json +16 -13
  51. package/dist/esm/i18n/locales/locales/it-IT.json +16 -13
  52. package/dist/esm/i18n/locales/locales/ja-JP.json +16 -13
  53. package/dist/esm/i18n/locales/locales/ko-KR.json +16 -13
  54. package/dist/esm/i18n/locales/locales/pt-PT.json +16 -13
  55. package/dist/esm/i18n/locales/locales/zh-CN.json +16 -13
  56. package/dist/esm/i18n/locales/pt-PT.json +16 -13
  57. package/dist/esm/i18n/locales/zh-CN.json +16 -13
  58. package/dist/esm/index.js +5 -1
  59. package/dist/esm/mixins/OxyServices.auth.js +14 -4
  60. package/dist/esm/mixins/OxyServices.identity.js +14 -0
  61. package/dist/esm/session/accountDialogController.js +334 -143
  62. package/dist/esm/utils/authWebUrl.js +7 -0
  63. package/dist/types/.tsbuildinfo +1 -1
  64. package/dist/types/crypto/identityMove.d.ts +59 -0
  65. package/dist/types/crypto/keyManager.d.ts +35 -5
  66. package/dist/types/crypto/webIdentityCarrier.d.ts +123 -0
  67. package/dist/types/index.d.ts +5 -2
  68. package/dist/types/mixins/OxyServices.auth.d.ts +12 -0
  69. package/dist/types/models/interfaces.d.ts +15 -0
  70. package/dist/types/session/accountDialogController.d.ts +169 -17
  71. package/dist/types/utils/authWebUrl.d.ts +7 -0
  72. package/package.json +3 -3
  73. package/src/crypto/__tests__/identityMove.test.ts +104 -0
  74. package/src/crypto/__tests__/keyManager.sharedSlotSync.test.ts +150 -0
  75. package/src/crypto/__tests__/webIdentityCarrier.test.ts +211 -0
  76. package/src/crypto/identityMove.ts +133 -0
  77. package/src/crypto/keyManager.ts +71 -21
  78. package/src/crypto/webIdentityCarrier.ts +326 -0
  79. package/src/i18n/locales/ar-SA.json +16 -13
  80. package/src/i18n/locales/ca-ES.json +16 -13
  81. package/src/i18n/locales/de-DE.json +16 -13
  82. package/src/i18n/locales/en-US.json +16 -17
  83. package/src/i18n/locales/es-ES.json +16 -17
  84. package/src/i18n/locales/fr-FR.json +16 -13
  85. package/src/i18n/locales/it-IT.json +16 -13
  86. package/src/i18n/locales/ja-JP.json +16 -13
  87. package/src/i18n/locales/ko-KR.json +16 -13
  88. package/src/i18n/locales/pt-PT.json +16 -13
  89. package/src/i18n/locales/zh-CN.json +16 -13
  90. package/src/index.ts +38 -1
  91. package/src/mixins/OxyServices.auth.ts +16 -3
  92. package/src/mixins/OxyServices.identity.ts +14 -0
  93. package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +47 -0
  94. package/src/mixins/__tests__/commonsSignIn.test.ts +3 -0
  95. package/src/mixins/__tests__/verifyChallenge.test.ts +31 -0
  96. package/src/models/interfaces.ts +15 -0
  97. package/src/session/__tests__/accountDialogController.test.ts +407 -10
  98. package/src/session/accountDialogController.ts +406 -145
  99. package/src/utils/__tests__/oauthPkce.test.ts +50 -26
  100. package/src/utils/authWebUrl.ts +8 -0
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Identity move — the crypto of taking a web identity into Commons.
3
+ *
4
+ * Both carriers run this: `id.oxy.so` (the initiator, holding the identity) and
5
+ * Commons (the responder, receiving it). The relay in between sees two ephemeral
6
+ * public keys and ciphertext; the 6-digit SAS both screens show is what makes a
7
+ * relay that swapped a key visible to the person.
8
+ *
9
+ * PURE: no storage, no network. Contract: `@oxy.so/contracts` `identityMove`.
10
+ */
11
+ import './polyfill';
12
+ import { type OpenedWebIdentity } from './webIdentityCarrier';
13
+ /** Actions the identity key signs during a move. */
14
+ export declare const IDENTITY_MOVE_ACTIONS: {
15
+ readonly seal: "identity_move_seal";
16
+ readonly received: "identity_move_received";
17
+ };
18
+ /** A fresh ephemeral key pair for one move. Keep the private key in memory only. */
19
+ export declare function generateMoveEphemeralKeyPair(): {
20
+ privateKey: string;
21
+ publicKey: string;
22
+ };
23
+ /** The symmetric key both sides derive: HKDF(ECDH, salt = moveId). */
24
+ export declare function deriveMoveKey(ownEphemeralPrivateKey: string, otherEphemeralPublicKey: string, moveId: string): Uint8Array;
25
+ /** The 6-digit code both screens show, bound to the move and both ephemeral keys in their roles. */
26
+ export declare function deriveMoveSas(moveId: string, initiatorEphemeralPublicKey: string, responderEphemeralPublicKey: string): string;
27
+ /** Initiator: seal the identity's entropy for the responder. */
28
+ export declare function sealIdentityForMove(identity: Pick<OpenedWebIdentity, 'mnemonic' | 'publicKey'>, moveKey: Uint8Array, moveId: string): {
29
+ nonce: string;
30
+ ciphertext: string;
31
+ };
32
+ /**
33
+ * Responder: open the sealed identity. The derived public key must be the one
34
+ * the move declared — anything else is refused, never imported.
35
+ */
36
+ export declare function openMovedIdentity(sealed: {
37
+ nonce: string;
38
+ ciphertext: string;
39
+ }, moveKey: Uint8Array, moveId: string, expectedPublicKey: string): OpenedWebIdentity;
40
+ /** The exact bytes a move proof or receipt signs. */
41
+ export declare function buildMoveMessage(action: string, moveId: string, timestamp: number): string;
42
+ /** Sign a move action with the identity key. */
43
+ export declare function signMoveAction(identity: Pick<OpenedWebIdentity, 'privateKey'>, action: string, moveId: string, timestamp?: number): Promise<{
44
+ signature: string;
45
+ timestamp: number;
46
+ }>;
47
+ /**
48
+ * Initiator: is this receipt really from the identity? Checked locally with the
49
+ * identity's public key, so a server that claims "completed" without Commons
50
+ * having the key cannot make the web destroy its copy.
51
+ */
52
+ export declare function verifyMoveReceipt(publicKey: string, moveId: string, receipt: {
53
+ signature: string;
54
+ timestamp: number;
55
+ }): Promise<boolean>;
56
+ /** The QR payload for a move. */
57
+ export declare function buildMoveQrPayload(moveId: string): string;
58
+ /** The move id in a scanned payload, or `null` when it is not a move QR. */
59
+ export declare function parseMoveQrPayload(raw: string): string | null;
@@ -230,6 +230,32 @@ export declare class KeyManager {
230
230
  * @returns Shared private key or null if no shared identity exists
231
231
  */
232
232
  static getSharedPrivateKey(): Promise<string | null>;
233
+ /**
234
+ * Which identity key this device is actually acting as, per slot.
235
+ *
236
+ * There are two slots and they can disagree. The shared slot is what every
237
+ * cross-app reader takes first — `deriveScopedSeed` (so: the FairCoin wallet
238
+ * Peable derives) and `getSharedPrivateKey` — while the primary slot is what
239
+ * signing and the server-facing identity use. A disagreement is invisible to
240
+ * both sides on its own, and it is money: a wallet derived from a stale
241
+ * shared key watches addresses that the key published in the DID will never
242
+ * produce, so payments land where the recipient cannot see them.
243
+ *
244
+ * `activePublicKey` is the one money derives from — the same `shared ?? primary`
245
+ * order `deriveScopedSeed` uses, so a caller can compare it against the key
246
+ * published for the account and refuse to receive rather than watch the wrong
247
+ * addresses.
248
+ *
249
+ * Public keys are canonicalized, so two encodings of the same key compare
250
+ * equal. On web both slots are null and `inSync` is true: nothing is stored,
251
+ * so nothing can disagree.
252
+ */
253
+ static getIdentityKeyState(): Promise<{
254
+ primaryPublicKey: string | null;
255
+ sharedPublicKey: string | null;
256
+ activePublicKey: string | null;
257
+ inSync: boolean;
258
+ }>;
233
259
  /**
234
260
  * Check if a shared identity exists (accessible across all Oxy apps)
235
261
  *
@@ -279,14 +305,18 @@ export declare class KeyManager {
279
305
  */
280
306
  static clearSharedSession(): Promise<void>;
281
307
  /**
282
- * Migrate local identity to shared identity
308
+ * Make the shared slot hold THIS device's identity key.
283
309
  *
284
- * Call this when upgrading existing apps to use shared identities.
285
- * Copies the device-specific identity to shared storage so it can
286
- * be accessed by other Oxy apps.
310
+ * The shared slot is the one every Oxy app on the device reads, so it has to
311
+ * agree with the primary slot. This creates it when it is empty (the original
312
+ * migration, for apps that predate the shared identity) and repairs it when it
313
+ * holds a different key.
287
314
  *
288
- * @returns True if migration was successful, false if no local identity exists
315
+ * @returns True when the shared slot ends up holding the device's key, false
316
+ * when there is no identity to share.
289
317
  */
318
+ static syncSharedIdentity(): Promise<boolean>;
319
+ /** @deprecated Renamed to {@link KeyManager.syncSharedIdentity}. */
290
320
  static migrateToSharedIdentity(): Promise<boolean>;
291
321
  /**
292
322
  * Atomically persist a key pair to secure storage with verification + backup.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Web identity carrier — the crypto of "one identity, two carriers".
3
+ *
4
+ * An Oxy identity is a BIP-39 mnemonic; its seed's first 32 bytes are the
5
+ * secp256k1 key (`recoveryPhrase.ts`). Commons keeps that key in the device
6
+ * keychain. This module lets a browser carry the SAME identity without Oxy ever
7
+ * being able to use it:
8
+ *
9
+ * entropy ──AEAD(DEK)──▶ sealedEntropy
10
+ * DEK ──AEAD(KEK_i)─▶ wraps[i] KEK_i = HKDF(PRF output of passkey i)
11
+ *
12
+ * The PRF output is produced inside the user's authenticator, behind user
13
+ * verification, and never leaves the page that asked for it. The envelope is
14
+ * therefore safe to store anywhere — the identity origin's IndexedDB and the
15
+ * server copy alike — and opens only with a registered passkey or the phrase.
16
+ *
17
+ * PURE: no storage, no network, no WebAuthn call, no platform globals beyond the
18
+ * CSPRNG the AEAD polyfill guarantees. The caller runs the ceremony and hands the
19
+ * PRF output in; everything here is deterministic given its inputs, so it is
20
+ * identical on web, Node and React Native, and fully unit-testable.
21
+ *
22
+ * Contract: `WebIdentityEnvelope` in `@oxy.so/contracts`.
23
+ */
24
+ import './polyfill';
25
+ import { type WebIdentityEnvelope, type WebIdentityWrap } from '@oxy.so/contracts';
26
+ /**
27
+ * The PRF input every identity-carrier ceremony evaluates (`prf.eval.first`).
28
+ *
29
+ * Fixed rather than per-wrap: discoverable sign-in has no credential list to key
30
+ * `evalByCredential` by, and each passkey's output is already distinct. A future
31
+ * scheme change bumps the label — and with it every KEK — instead of mutating it.
32
+ */
33
+ export declare const WEB_IDENTITY_PRF_INPUT: Uint8Array;
34
+ /** Why an envelope did not open. Every value is a normal outcome a UI can explain. */
35
+ export type WebIdentityUnlockFailure =
36
+ /** The passkey used is not one of the envelope's wraps. */
37
+ 'unknown-credential'
38
+ /**
39
+ * The passkey IS listed but its PRF output does not open its wrap — a synced
40
+ * copy returning a different PRF value, or a provider that silently changed it.
41
+ * Recoverable with another passkey or the phrase.
42
+ */
43
+ | 'prf-mismatch'
44
+ /** The envelope itself is malformed or tampered with. */
45
+ | 'corrupt';
46
+ export declare class WebIdentityUnlockError extends Error {
47
+ readonly failure: WebIdentityUnlockFailure;
48
+ constructor(failure: WebIdentityUnlockFailure, message: string);
49
+ }
50
+ /** The identity in usable form. Hold it for one operation, then {@link wipeBytes}. */
51
+ export interface OpenedWebIdentity {
52
+ mnemonic: string;
53
+ /** 32-byte secp256k1 private key, lowercase hex. */
54
+ privateKey: string;
55
+ /** Uncompressed SEC1 public key, lowercase hex (Oxy's canonical form). */
56
+ publicKey: string;
57
+ }
58
+ /** Overwrite a buffer holding secret material. Best effort — JS gives no stronger guarantee. */
59
+ export declare function wipeBytes(bytes: Uint8Array): void;
60
+ /**
61
+ * Derive the identity from a mnemonic exactly as Commons does
62
+ * (`RecoveryPhraseService`): seed → first 32 bytes → secp256k1.
63
+ */
64
+ export declare function deriveIdentityFromMnemonic(mnemonic: string): OpenedWebIdentity;
65
+ /** A brand-new 12-word identity. Nothing is persisted. */
66
+ export declare function generateWebIdentity(): OpenedWebIdentity;
67
+ /** A fresh random data key for a new envelope. */
68
+ export declare function generateDataKey(): Uint8Array;
69
+ /** The key-encryption key a passkey's PRF output yields for its own wrap. */
70
+ export declare function deriveKeyEncryptionKey(prfOutput: Uint8Array, credentialId: string): Uint8Array;
71
+ /** Wrap `dataKey` for one passkey. */
72
+ export declare function wrapDataKey(dataKey: Uint8Array, prfOutput: Uint8Array, credentialId: string, publicKey: string, now?: Date): WebIdentityWrap;
73
+ /**
74
+ * Seal an identity into a new envelope that opens with the given passkey.
75
+ *
76
+ * Returns the data key too, so the caller can add further wraps in the same
77
+ * session; wipe it when done.
78
+ */
79
+ export declare function sealWebIdentity(identity: Pick<OpenedWebIdentity, 'mnemonic' | 'publicKey'>, firstWrap: {
80
+ prfOutput: Uint8Array;
81
+ credentialId: string;
82
+ }, now?: Date): {
83
+ envelope: WebIdentityEnvelope;
84
+ dataKey: Uint8Array;
85
+ };
86
+ /** Recover the data key with one passkey's PRF output. */
87
+ export declare function unwrapDataKey(envelope: WebIdentityEnvelope, prfOutput: Uint8Array, credentialId: string): Uint8Array;
88
+ /**
89
+ * Open an envelope with its data key.
90
+ *
91
+ * The derived public key is checked against the envelope's own: a sealed entropy
92
+ * that decrypts into a different identity is treated as corruption, never used.
93
+ */
94
+ export declare function openWebIdentity(envelope: WebIdentityEnvelope, dataKey: Uint8Array): OpenedWebIdentity;
95
+ /** Unwrap and open in one step. */
96
+ export declare function unlockWebIdentity(envelope: WebIdentityEnvelope, prfOutput: Uint8Array, credentialId: string): OpenedWebIdentity;
97
+ /** Add (or replace) one passkey's wrap. Requires the data key of an unlocked envelope. */
98
+ export declare function addWrap(envelope: WebIdentityEnvelope, dataKey: Uint8Array, prfOutput: Uint8Array, credentialId: string, now?: Date): WebIdentityEnvelope;
99
+ /** Remove one passkey's wrap. The last wrap can never be removed — an envelope nobody can open is a loss. */
100
+ export declare function removeWrap(envelope: WebIdentityEnvelope, credentialId: string): WebIdentityEnvelope;
101
+ /**
102
+ * The message an identity key signs to authorize an account action
103
+ * (`link_identity`, `web_envelope_delete`, …): `JSON.stringify({ action, userId,
104
+ * timestamp })`, byte-identical to what the API reconstructs.
105
+ */
106
+ export declare function buildIdentityActionMessage(action: string, userId: string, timestamp: number): string;
107
+ /** Sign an identity-action message with an opened identity's key. */
108
+ export declare function signIdentityAction(identity: Pick<OpenedWebIdentity, 'privateKey'>, action: string, userId: string, timestamp?: number): Promise<{
109
+ signature: string;
110
+ timestamp: number;
111
+ }>;
112
+ /**
113
+ * The 6-digit short authentication string both sides of an identity transfer show.
114
+ *
115
+ * Bound to the pairing and to BOTH ephemeral public keys in their roles, so a relay
116
+ * that substitutes either key produces a different code on each screen. The user
117
+ * comparing the two codes is what makes the transfer safe against the relay itself.
118
+ */
119
+ export declare function deriveTransferSas(input: {
120
+ pairingId: string;
121
+ initiatorEphemeralPublicKey: string;
122
+ responderEphemeralPublicKey: string;
123
+ }): string;
@@ -70,6 +70,9 @@ export { hkdfSha256 } from './crypto/kdf';
70
70
  export { encryptAead, decryptAead, AEAD_KEY_LENGTH, AEAD_NONCE_LENGTH, } from './crypto/aead';
71
71
  export type { AeadResult } from './crypto/aead';
72
72
  export { deriveSharedSecret } from './crypto/ecdh';
73
+ export { WEB_IDENTITY_PRF_INPUT, WebIdentityUnlockError, addWrap, buildIdentityActionMessage, deriveIdentityFromMnemonic, deriveKeyEncryptionKey, deriveTransferSas, generateDataKey, generateWebIdentity, openWebIdentity, removeWrap, sealWebIdentity, signIdentityAction, unlockWebIdentity, unwrapDataKey, wipeBytes, wrapDataKey, } from './crypto/webIdentityCarrier';
74
+ export type { OpenedWebIdentity, WebIdentityUnlockFailure } from './crypto/webIdentityCarrier';
75
+ export { IDENTITY_MOVE_ACTIONS, buildMoveMessage, buildMoveQrPayload, deriveMoveKey, deriveMoveSas, generateMoveEphemeralKeyPair, openMovedIdentity, parseMoveQrPayload, sealIdentityForMove, signMoveAction, verifyMoveReceipt, } from './crypto/identityMove';
73
76
  export { DeviceManager } from './utils/deviceManager';
74
77
  export type { DeviceFingerprint, StoredDeviceInfo } from './utils/deviceManager';
75
78
  export type { OxyConfig, PrivacySettings, NotificationPreferences, UserPreferences, User, LoginResponse, Notification, Wallet, Transaction, BlockedUser, RestrictedUser, TransferFundsRequest, PurchaseRequest, WithdrawalRequest, TransactionResponse, PaginationInfo, SearchProfilesResponse, ApiError, PaymentMethod, PaymentRequest, PaymentResponse, AnalyticsData, FollowerDetails, ContentViewer, FileMetadata, FileUploadResponse, FileListResponse, FileUpdateRequest, FileDeleteResponse, RNFileDescriptor, AssetUploadInput, FileVisibility, AssetLink, AssetMetadata, AssetVariant, Asset, AssetInitRequest, AssetInitResponse, AssetCompleteRequest, AssetLinkRequest, AssetUnlinkRequest, AssetUrlResponse, BatchFileAccessEntry, BatchFileAccessResponse, AssetDeleteSummary, AssetUpdateVisibilityRequest, AssetUpdateVisibilityResponse, ServiceAssetMetadata, ServiceAssetMetadataBySha, AccountStorageCategoryUsage, AccountStorageUsageResponse, SecurityEventType, SecurityEventSeverity, SecurityActivity, SecurityActivityResponse, AssetUploadProgress, DeviceLinkedSession, DeviceLinkedSessionsResponse, DeviceLinkedSessionLogoutResponse, UpdateDeviceNameResponse, } from './models/interfaces';
@@ -104,7 +107,7 @@ export { updateAvatarVisibility } from './utils/avatarUtils';
104
107
  export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccountFallbackHandle, formatPublicKeyHandle, getAccountColor, } from './utils/accountUtils';
105
108
  export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
106
109
  export { registrableApex } from './utils/registrableApex';
107
- export { CENTRAL_IDP_APEX } from './utils/authWebUrl';
110
+ export { CENTRAL_IDP_APEX, IDENTITY_WEB_ORIGIN } from './utils/authWebUrl';
108
111
  export { isOxyRpOrigin } from './utils/webauthnOrigin';
109
112
  export { runColdBoot } from './utils/coldBoot';
110
113
  export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
@@ -125,7 +128,7 @@ export { isSwitchTargetAccount, canSwitchIntoAccount, } from './session/accountS
125
128
  export { resolveAccountDelegationAccess } from './session/accountDelegationAccess';
126
129
  export type { AccountDelegationAccess, AccountDelegationNode, } from './session/accountDelegationAccess';
127
130
  export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController';
128
- export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, PopupWindowHandle, SignInFlowPhase, SignInFlowState, SignInProgress, } from './session/accountDialogController';
131
+ export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, PopupWindowHandle, SignInFailureReason, SignInFlowPhase, SignInFlowState, SignInProgress, } from './session/accountDialogController';
129
132
  export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './session/authStateStore';
130
133
  export type { PersistedAuthState, AuthStateStore, NativeKeyValueStorage, } from './session/authStateStore';
131
134
  export { createSharedMirroringAuthStateStore, decideSharedDeviceJoin, decideSharedDevicePublish, normalizeSharedDeviceSessionRead, publishProvenDeviceCredential, readLocalDeviceCredential, } from './session/sharedDeviceCredential';
@@ -471,10 +471,16 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
471
471
  * `timeout`). Interactive callers omit it (defaults keep retries); the
472
472
  * cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
473
473
  * network cannot multiply boot latency via the inner retry loop.
474
+ * @param options.plantTokens - Install the returned bearer on this client
475
+ * (default `true`). A caller that must decide LATER whether the session is
476
+ * still wanted (the account dialog, whose user may have cancelled while the
477
+ * request was in flight) passes `false` and plants it itself.
474
478
  */
475
479
  verifyChallenge(publicKey: string, challenge: string, signature: string, timestamp: number, deviceName?: string, deviceFingerprint?: string, requestOptions?: {
476
480
  retry?: boolean;
477
481
  timeout?: number;
482
+ }, options?: {
483
+ plantTokens?: boolean;
478
484
  }): Promise<SessionLoginResponse>;
479
485
  /**
480
486
  * Check if a public key is already registered
@@ -515,9 +521,13 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
515
521
  * `POST /auth/session/create` at the start of the flow.
516
522
  * @param options.deviceFingerprint - Optional fingerprint of the
517
523
  * originating client device.
524
+ * @param options.plantTokens - Install the claimed bearer on this client
525
+ * (default `true`). Pass `false` to install it yourself once you know the
526
+ * session is still wanted — see `verifyChallenge`.
518
527
  */
519
528
  claimSessionByToken(sessionToken: string, options?: {
520
529
  deviceFingerprint?: string;
530
+ plantTokens?: boolean;
521
531
  }): Promise<{
522
532
  accessToken: string;
523
533
  sessionId: string;
@@ -545,6 +555,7 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
545
555
  * retries); the cold-boot `shared-key-signin` step passes `{ retry: false }`
546
556
  * so this network step cannot multiply boot latency via the inner retry
547
557
  * loop. The token-refresh scheduler / 401 lane still retry later.
558
+ * @param opts.plantTokens - Forwarded to `verifyChallenge` (default `true`).
548
559
  */
549
560
  signInWithSharedIdentity(opts?: {
550
561
  deviceName?: string;
@@ -553,6 +564,7 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
553
564
  retry?: boolean;
554
565
  timeout?: number;
555
566
  };
567
+ plantTokens?: boolean;
556
568
  }): Promise<SessionLoginResponse | null>;
557
569
  /**
558
570
  * MECHANISM B (relying party) — begin a "Sign in with Oxy" handoff.
@@ -101,7 +101,22 @@ export interface User {
101
101
  description?: string;
102
102
  phone?: string;
103
103
  address?: string;
104
+ /** Legacy free-text birthday, kept for backward compatibility. Prefer `dateOfBirth`. */
104
105
  birthday?: string;
106
+ /**
107
+ * The structured date of birth, `YYYY-MM-DD`. The new source of truth going
108
+ * forward — `birthday` above is unparsed free text with no guaranteed
109
+ * shape. Present only on the account's OWN profile (never on another
110
+ * account's public profile), same visibility as `phone`/`address`/`birthday`.
111
+ */
112
+ dateOfBirth?: string | null;
113
+ /**
114
+ * Derived, non-PII signal: whether the account holder is at least 18.
115
+ * `undefined` when `dateOfBirth` is unset ("unknown"), distinct from
116
+ * `false` ("known, not yet 18"). Never stored — computed fresh by the API
117
+ * on every read of the owner's own profile.
118
+ */
119
+ isAdult?: boolean;
105
120
  website?: string;
106
121
  createdAt?: string;
107
122
  updatedAt?: string;
@@ -110,6 +110,21 @@ export type SignInFlowPhase = 'idle' | 'starting' | 'waiting' | 'authorized' | '
110
110
  * evidence of an approval. Only `authorized` moves the flow forward.
111
111
  */
112
112
  export type SignInProgress = 'idle' | 'preparing' | 'awaiting-approval' | 'delivered-to-commons' | 'opened-in-commons' | 'confirming-identity' | 'identity-confirmed';
113
+ /**
114
+ * WHY a sign-in attempt ended in `'error'` — a machine-readable reason the UI
115
+ * turns into localized copy (the controller ships no user-facing prose).
116
+ *
117
+ * - `'cancelled'` — the USER walked away (closed the passkey hub popup). A
118
+ * voluntary outcome, not a failure to report.
119
+ * - `'denied'` — the approver declined the request in Commons.
120
+ * - `'expired'` — the request outlived its server-authoritative expiry.
121
+ * - `'network'` — Oxy could not be reached (no response, timeout).
122
+ * - `'not-configured'` — the app has no registered `clientId`.
123
+ * - `'unsupported-flow'` — an OAuth-bound request, which this surface cannot finalize.
124
+ * - `'claim-failed'` — the request WAS approved, but the session could not be claimed.
125
+ * - `'unknown'` — anything else; the raw message stays on `error` for logs.
126
+ */
127
+ export type SignInFailureReason = 'cancelled' | 'denied' | 'expired' | 'network' | 'not-configured' | 'unsupported-flow' | 'claim-failed' | 'unknown';
113
128
  /**
114
129
  * Minimal structural handle over a popup `Window` — just enough for the
115
130
  * cross-origin passkey hub flow ({@link AccountDialogController.startPasskeyHubSignIn}):
@@ -147,8 +162,13 @@ export interface SignInFlowState {
147
162
  qrPayload: string | null;
148
163
  /** Server-authoritative expiry (epoch ms), or `null`. */
149
164
  expiresAt: number | null;
150
- /** Human-readable error for the retry UI, or `null`. */
165
+ /**
166
+ * The raw, UNLOCALIZED failure message (diagnostics only), or `null`. A surface
167
+ * renders {@link failure} instead — this string is English or a server message.
168
+ */
151
169
  error: string | null;
170
+ /** Why the attempt failed, set together with `phase: 'error'`; `null` otherwise. */
171
+ failure: SignInFailureReason | null;
152
172
  /**
153
173
  * The ONE primary delivery route the controller chose for this request
154
174
  * ({@link selectCommonsDelivery}), or `null` while it is still being resolved.
@@ -189,6 +209,14 @@ export interface SignInFlowState {
189
209
  * cannot drift from them or run ahead of a real signal.
190
210
  */
191
211
  progress: SignInProgress;
212
+ /**
213
+ * Identity of the sign-in ATTEMPT this state belongs to — a counter that moves
214
+ * whenever an attempt is started, cancelled, or the controller is destroyed.
215
+ * Not a secret. It lets a surface treat "this attempt failed" as one event (a
216
+ * re-notification of the same failure is not a second failure, while a new
217
+ * attempt failing with the same message is).
218
+ */
219
+ attempt: number;
192
220
  }
193
221
  /** Immutable snapshot consumed by `useSyncExternalStore`. */
194
222
  export interface AccountDialogSnapshot {
@@ -290,8 +318,16 @@ export interface AccountDialogControllerOptions {
290
318
  */
291
319
  openPopup?: () => PopupWindowHandle | null;
292
320
  /**
293
- * Base origin of the auth.oxy.so passkey hub (defaults to
294
- * `https://auth.${CENTRAL_IDP_APEX}`). Overridable for local/staging testing.
321
+ * Origin of the web identity carrier (default `IDENTITY_WEB_ORIGIN`,
322
+ * `https://id.oxy.so`) — where a passkey sign-in or sign-up runs
323
+ * and the account's identity is kept sealed under the passkey. The popup opens
324
+ * `<identityOrigin>/continue?code=…`. Overridable for local/staging testing.
325
+ */
326
+ identityOrigin?: string;
327
+ /**
328
+ * @deprecated Alias of {@link identityOrigin}, kept for existing
329
+ * configuration. The popup used to open `auth.oxy.so/hub-passkey`; it now opens
330
+ * the identity origin's `/continue`, so a value here must be that origin.
295
331
  */
296
332
  hubBaseUrl?: string;
297
333
  /**
@@ -319,7 +355,7 @@ export declare class AccountDialogController {
319
355
  private readonly canOpenApp?;
320
356
  private readonly socketFactory?;
321
357
  private readonly openPopup?;
322
- private readonly hubBaseUrl;
358
+ private readonly identityOrigin;
323
359
  private readonly platform;
324
360
  private readonly listeners;
325
361
  private view;
@@ -328,8 +364,24 @@ export declare class AccountDialogController {
328
364
  private activatingContextId;
329
365
  private removingContextId;
330
366
  private removingPrincipalId;
367
+ /**
368
+ * `true` while an operation run through {@link runDeviceMutation} (a host
369
+ * sign-out) is in flight. The fourth member of the ONE device-mutation gate,
370
+ * alongside the three flags above.
371
+ */
372
+ private exclusiveMutationInFlight;
331
373
  private signIn;
332
374
  private commonsAvailability;
375
+ /**
376
+ * The CURRENT sign-in attempt. Every asynchronous step of a sign-in captures
377
+ * it before awaiting and re-checks it after (`isCurrentAttempt`); cancelling,
378
+ * starting another attempt, or destroying the controller moves it, so a late
379
+ * response from an abandoned attempt can neither update the surface nor
380
+ * install a session.
381
+ */
382
+ private signInAttempt;
383
+ /** How the current attempt was started, so a retry repeats the user's choice. */
384
+ private signInMethod;
333
385
  /** The secret device-flow token of the active QR flow (never surfaced). */
334
386
  private signInToken;
335
387
  private pollTimer;
@@ -342,9 +394,10 @@ export declare class AccountDialogController {
342
394
  /**
343
395
  * Guards {@link pollOnce} against re-entrancy: the fallback timer and a socket
344
396
  * `auth_update` wake can fire together — without this both could claim the
345
- * single-use token concurrently.
397
+ * single-use token concurrently. Keyed by token, so a poll still running for an
398
+ * ABANDONED request never blocks the next request's first poll.
346
399
  */
347
- private pollInFlight;
400
+ private pollInFlightToken;
348
401
  /** The popup opened by {@link startPasskeyHubSignIn}, or `null`. */
349
402
  private activePopup;
350
403
  /** Watches {@link activePopup}'s `closed` state; see {@link watchPopup}. */
@@ -412,8 +465,11 @@ export declare class AccountDialogController {
412
465
  * — and the reconstruction was not merely redundant: it enumerated the
413
466
  * CALLER's account graph, which on a device holding two people is one
414
467
  * person's answer presented as the device's.
468
+ *
469
+ * Resolves `false` when THIS read failed (a 401 is the signed-out edge, not a
470
+ * failure). Never rejects.
415
471
  */
416
- refresh(): Promise<void>;
472
+ refresh(): Promise<boolean>;
417
473
  /**
418
474
  * Activate one `principal acting as account` context — the ADR 0002 switch,
419
475
  * and the one that can express what an account id cannot: WHICH person's
@@ -427,6 +483,11 @@ export declare class AccountDialogController {
427
483
  * A context id is not stable across a removal, so a stale one is an ordinary
428
484
  * outcome rather than a bug: the server answers 404 or 403, heals the row, and
429
485
  * the refresh below re-reads a directory that no longer offers it.
486
+ *
487
+ * Resolves `true` once the SWITCH happened — even if the directory re-read
488
+ * after it fails (that failure lands on `error`, but the subject did change).
489
+ * Resolves `false` when the switch failed, or was refused because another
490
+ * device mutation is in flight ({@link isDeviceMutationInFlight}).
430
491
  */
431
492
  activateContext(contextId: string): Promise<boolean>;
432
493
  /**
@@ -451,11 +512,46 @@ export declare class AccountDialogController {
451
512
  * and elects a replacement active context in one transition.
452
513
  */
453
514
  signOutPrincipal(principalId: string): Promise<boolean>;
515
+ /**
516
+ * Whether ANY operation that changes who is on this device, or which of them
517
+ * is active, is in flight: an activation, either removal, or a host operation
518
+ * run through {@link runDeviceMutation}.
519
+ *
520
+ * They share ONE gate because they mutate the same device set: a removal that
521
+ * elects a replacement active context racing a switch could leave either one
522
+ * active. A refused call is refused, never queued — a press that could not run
523
+ * must not fire later, after the user has moved on.
524
+ */
525
+ isDeviceMutationInFlight(): boolean;
526
+ /**
527
+ * Run a device mutation the controller does not own (the host's sign-out)
528
+ * under the same gate as {@link activateContext} and the removals.
529
+ *
530
+ * Resolves `{ ran: false }` without calling `operation` when another mutation
531
+ * is in flight; otherwise `{ ran: true, value }`. A rejection from `operation`
532
+ * propagates, after the gate is released.
533
+ */
534
+ runDeviceMutation<T>(operation: () => Promise<T>): Promise<{
535
+ ran: true;
536
+ value: T;
537
+ } | {
538
+ ran: false;
539
+ }>;
540
+ /**
541
+ * The shared body of the three controller-owned device mutations: take the
542
+ * gate, run the mutation, then re-read the directory.
543
+ *
544
+ * The mutation's outcome and the re-read's are kept apart. Once `operation`
545
+ * has resolved the device HAS changed, so this resolves `true` even when the
546
+ * re-read fails — that failure is the directory's (`error`), and reporting it
547
+ * as a failed switch would invite the user to repeat something that happened.
548
+ */
549
+ private mutateDevice;
454
550
  /**
455
551
  * Start "Sign in with Oxy". Native devices with a shared identity mint a
456
552
  * session silently (`signInWithSharedIdentity`); everything else (web, or a
457
553
  * native device without a shared identity) falls through to the cross-device
458
- * QR handoff.
554
+ * QR handoff — as the SAME attempt, so cancelling during either half stops both.
459
555
  */
460
556
  signInWithOxy(): Promise<void>;
461
557
  /**
@@ -466,11 +562,24 @@ export declare class AccountDialogController {
466
562
  */
467
563
  showQr(): Promise<void>;
468
564
  /**
469
- * Web-only: "Sign in with a passkey" on a non-Oxy origin cannot run the
470
- * WebAuthn ceremony locally — a credential minted with `WEBAUTHN_RP_ID=oxy.so`
471
- * can only be asserted from `oxy.so`/a subdomain/loopback, a browser-enforced
472
- * boundary `isOxyRpOrigin()` (consumer-side) already gates on. Instead, open
473
- * a popup at the auth.oxy.so passkey hub, scoped to the SAME device-flow
565
+ * Start a NEW attempt the same way the last one was started — "Try again"
566
+ * repeats the user's choice rather than silently switching method. A failed
567
+ * passkey-hub attempt reopens the hub popup; a failed "Sign in with Oxy"
568
+ * retries that; an explicit QR request shows the QR.
569
+ *
570
+ * Call it straight from the press handler: the hub route opens its popup
571
+ * synchronously, before this method's first `await`, so the browser still
572
+ * attributes it to the click.
573
+ */
574
+ retrySignIn(): Promise<void>;
575
+ /**
576
+ * Web-only: sign in (or create an account) with a passkey at the web identity
577
+ * carrier. The ceremony runs on `id.oxy.so`, never on the calling origin, for
578
+ * two reasons: a credential minted with `WEBAUTHN_RP_ID=oxy.so` can only be
579
+ * asserted from `oxy.so`/a subdomain/loopback (a browser-enforced boundary),
580
+ * and only the identity origin may unseal or create the account's identity
581
+ * (one identity, two carriers). Opens a popup at `<identityOrigin>/continue`,
582
+ * scoped to the SAME device-flow
474
583
  * session {@link showQr} would create (same `authorizeCode`/`sessionToken`
475
584
  * pair), and let the SAME poll/socket/claim engine complete it once the hub
476
585
  * authorizes the session (`POST /auth/session/authorize-code/:authorizeCode`,
@@ -485,13 +594,26 @@ export declare class AccountDialogController {
485
594
  * `showQr`'s plain QR rendering — no dead end.
486
595
  */
487
596
  startPasskeyHubSignIn(): Promise<void>;
597
+ /**
598
+ * End whatever attempt is running (withdrawing its request) and begin a new
599
+ * one. Returns the new attempt's identity for the caller to re-check after
600
+ * every `await`.
601
+ */
602
+ private beginSignInAttempt;
603
+ /** Whether `attempt` is still the one the surface is waiting on. */
604
+ private isCurrentAttempt;
488
605
  /**
489
606
  * Shared device-flow session creation for both {@link showQr} (renders the
490
607
  * QR) and {@link startPasskeyHubSignIn} (also opens the hub popup) — the
491
608
  * same `startCommonsSignIn` → poll/socket wiring either way. Returns the
492
- * handle on success (already reflected in `signIn`), or `null` on failure
493
- * (already set as `signIn.error`).
609
+ * handle on success (already reflected in `signIn`), or `null` when the
610
+ * attempt failed (already set as `signIn.failure`) or was abandoned while the
611
+ * request was being created.
494
612
  *
613
+ * @param attempt - The attempt this request belongs to. A request that comes
614
+ * back for an abandoned attempt is withdrawn server-side and never wired up:
615
+ * closing the dialog on a slow connection must not leave an approvable
616
+ * request, a socket, and a poll behind it.
495
617
  * @param opts.deliver - Whether to run automatic Commons delivery selection
496
618
  * ({@link resolveDeliveryRoute}). `true` for the normal one-primary-action
497
619
  * entry; `false` when the caller already owns the primary surface (the
@@ -574,6 +696,12 @@ export declare class AccountDialogController {
574
696
  * cancel the request, not just stop listening to it. Without the withdrawal a
575
697
  * dismissed QR would stay approvable until it expired, so a later scan of a
576
698
  * stale code could authorize a session nobody is waiting for.
699
+ *
700
+ * It also ENDS the attempt: anything of it still awaiting a response (the
701
+ * request being created, a shared-identity mint, a claim) finds itself
702
+ * superseded when that response arrives, and neither touches the surface nor
703
+ * installs a session. The withdrawal itself is best effort — cancelling
704
+ * locally is not proof the server received it (see {@link withdrawRequest}).
577
705
  */
578
706
  cancelSignIn(): void;
579
707
  /**
@@ -603,8 +731,28 @@ export declare class AccountDialogController {
603
731
  private recordDeliveryProgress;
604
732
  private claimAndComplete;
605
733
  /**
606
- * Commit an authorized session, notify, and return to the account list. Shared
607
- * by the shared-key, QR, and mint-switch paths so they cannot drift.
734
+ * Install an authorized session, notify, and return to the account list.
735
+ * Shared by the shared-key and QR paths so they cannot drift.
736
+ *
737
+ * THE install point, and so the last place an abandoned attempt is stopped:
738
+ * the bearer is planted and the session committed only while `attempt` is
739
+ * still current. A commit that fails restores the bearer the client had
740
+ * before, so a half-installed session never leaves requests going out as an
741
+ * account the device does not hold.
742
+ *
743
+ * A failed commit REJECTS (after that restore), so each caller keeps its own
744
+ * policy: the QR path fails the attempt, the shared-identity path falls
745
+ * through to the QR handoff.
746
+ *
747
+ * Once the commit has resolved the session IS this device's, whether or not
748
+ * the user moved on while it ran. It is reported (`onSignedIn`, re-read) either
749
+ * way; only the surface bookkeeping is skipped for a superseded attempt, so a
750
+ * newer attempt's state is never overwritten.
751
+ *
752
+ * Residual: an attempt abandoned after the server minted its session (a claim
753
+ * or shared-identity mint that was already in flight) leaves that server
754
+ * session un-installed on this device — it is never planted or committed here,
755
+ * but it is not revoked either.
608
756
  */
609
757
  private completeSignIn;
610
758
  /**
@@ -613,6 +761,10 @@ export declare class AccountDialogController {
613
761
  * `SessionClient.registerAndActivate` (registration + activation only).
614
762
  */
615
763
  private commitAuthorizedSession;
764
+ /**
765
+ * End the CURRENT attempt as failed. The attempt keeps its identity (the
766
+ * failure belongs to it); only a new attempt or a cancel moves it on.
767
+ */
616
768
  private failSignIn;
617
769
  private clearPollTimer;
618
770
  /**
@@ -14,3 +14,10 @@
14
14
  * the CORS helper (and anything else that needs the central apex) never drifts.
15
15
  */
16
16
  export declare const CENTRAL_IDP_APEX = "oxy.so";
17
+ /**
18
+ * The web identity carrier's origin — where a person signs in or creates an
19
+ * account with a passkey, and where their self-custody identity is kept sealed
20
+ * under that passkey (one identity, two carriers). Apps open it; they never
21
+ * handle the identity themselves.
22
+ */
23
+ export declare const IDENTITY_WEB_ORIGIN = "https://id.oxy.so";