@oxyhq/core 12.8.0 → 12.9.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 (38) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +16 -3
  3. package/dist/cjs/crypto/identityMarker.js +255 -0
  4. package/dist/cjs/crypto/keyManager.js +844 -106
  5. package/dist/cjs/index.js +8 -4
  6. package/dist/cjs/mixins/OxyServices.auth.js +21 -6
  7. package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
  8. package/dist/esm/.tsbuildinfo +1 -1
  9. package/dist/esm/boot/sessionColdBoot.js +16 -3
  10. package/dist/esm/crypto/identityMarker.js +248 -0
  11. package/dist/esm/crypto/keyManager.js +843 -106
  12. package/dist/esm/index.js +2 -1
  13. package/dist/esm/mixins/OxyServices.auth.js +21 -6
  14. package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
  15. package/dist/types/.tsbuildinfo +1 -1
  16. package/dist/types/boot/sessionColdBoot.d.ts +25 -0
  17. package/dist/types/crypto/identityMarker.d.ts +94 -0
  18. package/dist/types/crypto/keyManager.d.ts +212 -3
  19. package/dist/types/index.d.ts +4 -2
  20. package/dist/types/mixins/OxyServices.auth.d.ts +27 -2
  21. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
  22. package/package.json +1 -1
  23. package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
  24. package/src/boot/sessionColdBoot.ts +42 -3
  25. package/src/crypto/__tests__/identityMocks.ts +125 -0
  26. package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
  27. package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
  28. package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
  29. package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
  30. package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
  31. package/src/crypto/__tests__/keyManager.test.ts +77 -87
  32. package/src/crypto/identityMarker.ts +291 -0
  33. package/src/crypto/keyManager.ts +1026 -105
  34. package/src/index.ts +7 -1
  35. package/src/mixins/OxyServices.auth.ts +31 -7
  36. package/src/mixins/OxyServices.deviceBoot.ts +9 -1
  37. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
  38. package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
@@ -46,6 +46,31 @@ export interface RunSessionColdBootOptions {
46
46
  /** Invoked when the boot ended signed out. */
47
47
  onSignedOut?: (reason: SignedOutReason) => void | Promise<void>;
48
48
  onStepError?: (id: string, error: unknown) => void;
49
+ /**
50
+ * HARD overall deadline (ms) for the whole ordered step chain, forwarded to
51
+ * {@link runColdBoot}. Defense-in-depth so a single non-settling network step
52
+ * (a black-hole network that neither connects nor rejects) can NEVER hang the
53
+ * boot — and therefore app routing — indefinitely. Inert on healthy loads
54
+ * (every step settles well under it); only trips on pathological networks.
55
+ * When omitted there is no overall deadline (unchanged behavior).
56
+ */
57
+ overallDeadlineMs?: number;
58
+ /**
59
+ * Invoked once per step abandoned because {@link overallDeadlineMs} expired
60
+ * before it settled. Forwarded to {@link runColdBoot}. Must not throw.
61
+ */
62
+ onStepDeadline?: (stepId: string) => void;
63
+ /**
64
+ * Best-effort connectivity hint. When it returns `true` the two NETWORK steps
65
+ * (`device-secret-mint`, `shared-key-signin`) are skipped — an offline device
66
+ * cannot mint, and attempting to would burn the whole deadline on a doomed
67
+ * request before routing settles. The pure-local `warm-token-plant` step is
68
+ * NEVER gated by this: an offline returning user with an unexpired persisted
69
+ * token must still boot authenticated. Only an EXPLICIT offline verdict should
70
+ * be returned; the caller resolves unknown/timeout to `false` (assume online)
71
+ * so a flaky probe can never falsely skip a real sign-in.
72
+ */
73
+ isOffline?: () => boolean;
49
74
  }
50
75
  /**
51
76
  * Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Identity marker — a NON-secret, AndroidKeyStore-independent record that an
3
+ * identity exists (or existed) on this device.
4
+ *
5
+ * WHY THIS EXISTS: the identity private/public keys live in expo-secure-store,
6
+ * whose Android backing (a single `key_v1` AndroidKeyStore key by default) can be
7
+ * invalidated by an OS/vendor keystore event. When that happens SDK 57's
8
+ * expo-secure-store DELETES the undecryptable ciphertext on the read path and
9
+ * returns `null` — indistinguishable, from the keys alone, from a genuinely
10
+ * fresh install. That ambiguity is what lets a real identity get silently
11
+ * replaced by the onboarding "create" flow.
12
+ *
13
+ * The marker breaks the tie. It is written to AsyncStorage (RN) / localStorage
14
+ * (web) — storage that is NOT protected by the identity's AndroidKeyStore key —
15
+ * so it SURVIVES a keystore death. `getIdentityStatus()` reads it: keys empty +
16
+ * marker present ⇒ `lost` (route to recovery, NEVER welcome/create); keys empty
17
+ * + no marker ⇒ `absent` (the only path to fresh onboarding).
18
+ *
19
+ * It holds only the PUBLIC key plus provenance metadata — never any secret — so
20
+ * persisting it in plain KV storage adds no exposure.
21
+ *
22
+ * Every operation fails OPEN (returns null / false / resolves): the marker is a
23
+ * best-effort disambiguation signal layered on top of the authoritative
24
+ * secure-store reads, never a gate that can itself lock the user out.
25
+ *
26
+ * ESM-safe (no `require()`); zero React/RN static imports — the RN AsyncStorage
27
+ * module is reached only through `@oxyhq/protocol`'s per-platform dynamic loader.
28
+ */
29
+ /**
30
+ * AsyncStorage / localStorage key holding the serialized {@link IdentityMarker}.
31
+ * `.v1` lets a future shape change ship a `.v2` key without misreading a stale
32
+ * blob. Distinct from every `oxy_identity_*` secure-store key so it never
33
+ * collides with the keychain material it disambiguates.
34
+ */
35
+ export declare const IDENTITY_MARKER_STORAGE_KEY = "oxy_identity_marker_v1";
36
+ /**
37
+ * A durable, non-secret record that an identity was provisioned on this device.
38
+ *
39
+ * `publicKey` is the identity's public key (NOT secret) — it lets recovery
40
+ * validate that whatever it restores is the SAME account this marker records,
41
+ * never a silent account switch. `origin` records how the identity came to be.
42
+ * `onboardingComplete` mirrors the onboarding milestone (Workstream 3.4) so a
43
+ * lost SecureStore milestone flag cannot re-route a real identity into the
44
+ * onboarding wizard.
45
+ */
46
+ export interface IdentityMarker {
47
+ v: 1;
48
+ /** The identity's PUBLIC key — never secret. */
49
+ publicKey: string;
50
+ createdAt: number;
51
+ origin: 'create' | 'import' | 'restore' | 'backfill';
52
+ /** Milestone mirror: `true` once onboarding has completed for this identity. */
53
+ onboardingComplete?: boolean;
54
+ }
55
+ /** Fields accepted when creating a marker; `createdAt` defaults to now. */
56
+ export interface WriteIdentityMarkerInput {
57
+ publicKey: string;
58
+ origin: IdentityMarker['origin'];
59
+ createdAt?: number;
60
+ onboardingComplete?: boolean;
61
+ }
62
+ /**
63
+ * Read the identity marker. Fails OPEN: returns `null` on any storage error,
64
+ * missing entry, or malformed blob — the caller treats "no marker" as the safe
65
+ * default (fresh install), and the authoritative secure-store read decides the
66
+ * rest.
67
+ */
68
+ export declare function readIdentityMarker(): Promise<IdentityMarker | null>;
69
+ /**
70
+ * Write (create/replace) the marker. Returns `true` when it durably landed,
71
+ * `false` when storage was unavailable or the write threw. Callers treat a
72
+ * `false` as non-fatal — the marker is best-effort and a subsequent read
73
+ * re-backfills it from the healthy key pair.
74
+ */
75
+ export declare function writeIdentityMarker(input: WriteIdentityMarkerInput): Promise<boolean>;
76
+ /**
77
+ * Merge a partial update into the existing marker, preserving every field the
78
+ * caller does not override (notably `createdAt` and `onboardingComplete`). When
79
+ * no marker exists yet, a partial carrying at least `publicKey` + `origin`
80
+ * creates one; otherwise the update is a no-op returning `false`.
81
+ *
82
+ * Used to (a) mirror the onboarding milestone (`{ onboardingComplete: true }`)
83
+ * without disturbing provenance, and (b) refresh `origin` on a same-identity
84
+ * re-persist without resetting `createdAt`.
85
+ */
86
+ export declare function updateIdentityMarker(partial: Partial<Omit<IdentityMarker, 'v'>>): Promise<boolean>;
87
+ /**
88
+ * Remove the marker. Called ONLY after the identity's keys have been
89
+ * successfully deleted (`KeyManager.deleteIdentity`), so a marker never outlives
90
+ * the identity it records. Fails open (swallows errors) — a leftover marker
91
+ * simply routes a truly-absent identity to `recovery` instead of `welcome`,
92
+ * which is the safe direction.
93
+ */
94
+ export declare function clearIdentityMarker(): Promise<void>;
@@ -5,6 +5,7 @@
5
5
  * Private keys are stored securely using expo-secure-store and never leave the device.
6
6
  */
7
7
  import type { ECKeyPair } from 'elliptic';
8
+ import { type IdentityMarker } from './identityMarker';
8
9
  /**
9
10
  * Thrown when an identity-mutating operation (createIdentity / importKeyPair)
10
11
  * is invoked while a valid identity already exists on the device.
@@ -30,6 +31,64 @@ export declare class IdentityPersistError extends Error {
30
31
  readonly name = "IdentityPersistError";
31
32
  constructor(message: string, cause?: unknown | undefined);
32
33
  }
34
+ /**
35
+ * Thrown when identity storage cannot be read/written right now — the keychain
36
+ * is locked, the module failed to load, or a read threw — as opposed to the
37
+ * identity being genuinely absent.
38
+ *
39
+ * This is the crux of the corruption-vs-fresh-install fix: a storage THROW must
40
+ * NEVER be flattened into "no identity" (the old behavior, which let onboarding
41
+ * treat a momentarily-locked keystore as a blank device). Callers that used to
42
+ * tolerate a `false`/`null` from `hasIdentity()`/`getPublicKey()` on error must
43
+ * now treat this typed error as "cannot determine" — retry, surface a locked
44
+ * state, or abort a destructive path — never as "safe to create/overwrite".
45
+ */
46
+ export declare class IdentityUnavailableError extends Error {
47
+ readonly cause?: unknown | undefined;
48
+ readonly name = "IdentityUnavailableError";
49
+ constructor(message: string, cause?: unknown | undefined);
50
+ }
51
+ /**
52
+ * Authoritative tri-state (plus `unavailable`) verdict on the on-device
53
+ * identity, from {@link KeyManager.getIdentityStatus}.
54
+ *
55
+ * - `present` — a healthy, round-tripping key pair exists.
56
+ * - `absent` — storage read succeeded and returned nothing, AND no marker
57
+ * records a prior identity → a genuine fresh device. The ONLY
58
+ * state that may route to create/onboarding.
59
+ * - `lost` — storage read succeeded but the keys are empty/unreadable while
60
+ * the independent {@link IdentityMarker} records that an identity
61
+ * DID exist here → corruption/keystore death. Route to recovery,
62
+ * NEVER to create.
63
+ * - `unavailable` — a storage read THREW (keychain locked, module load failure).
64
+ * Transient by assumption; NEVER cached; callers retry.
65
+ */
66
+ export type IdentityStatus = {
67
+ state: 'present';
68
+ publicKey: string;
69
+ } | {
70
+ state: 'absent';
71
+ } | {
72
+ state: 'lost';
73
+ marker: IdentityMarker;
74
+ } | {
75
+ state: 'unavailable';
76
+ cause: unknown;
77
+ };
78
+ /**
79
+ * Result of {@link KeyManager.attemptIdentityRecovery}. On success it reports
80
+ * which independent, `key_v1`-surviving source restored the identity. On failure
81
+ * `reason` distinguishes "wasn't lost", "no surviving source", "a source held a
82
+ * DIFFERENT account" (never silently switched), and "storage unavailable".
83
+ */
84
+ export type IdentityRecoveryResult = {
85
+ recovered: true;
86
+ source: 'backup' | 'shared';
87
+ publicKey: string;
88
+ } | {
89
+ recovered: false;
90
+ reason: 'not-lost' | 'no-sources' | 'mismatch' | 'unavailable';
91
+ };
33
92
  export interface KeyPair {
34
93
  publicKey: string;
35
94
  privateKey: string;
@@ -39,11 +98,86 @@ export declare class KeyManager {
39
98
  private static cachedHasIdentity;
40
99
  private static cachedSharedPublicKey;
41
100
  private static cachedHasSharedIdentity;
101
+ /**
102
+ * Distinguishes "public key genuinely absent (a successful empty read, safe to
103
+ * cache)" from "never resolved / storage threw (must NOT be cached)". A `null`
104
+ * {@link cachedPublicKey} alone is ambiguous — this flag makes the genuine
105
+ * absence cacheable WITHOUT ever caching a null produced by a thrown read.
106
+ */
107
+ private static cachedPublicKeyResolved;
108
+ /** Listeners notified synchronously whenever the identity verdict may have changed. */
109
+ private static readonly identityChangeListeners;
110
+ /**
111
+ * Memoized one-run-per-process slot migration. `slotMigrationResult` caches a
112
+ * STABLE outcome (`v2`/`legacy`); a `deferred` outcome is intentionally not
113
+ * cached (the in-flight promise is cleared) so a later call retries once the
114
+ * keychain unlocks.
115
+ */
116
+ private static slotMigrationPromise;
117
+ private static slotMigrationResult;
42
118
  /**
43
119
  * Invalidate cached identity state
44
120
  * Called internally when identity is created/deleted/imported
45
121
  */
46
122
  private static invalidateCache;
123
+ /**
124
+ * Subscribe to identity-verdict changes (create / import / delete / restore /
125
+ * cache invalidation). Fires synchronously; the returned function unsubscribes.
126
+ * Consumed via `useOxyEvent`-style hooks in commons to invalidate the routing
127
+ * queries the instant the identity state moves, without polling.
128
+ */
129
+ static subscribeIdentityChanged(listener: () => void): () => void;
130
+ /** Synchronous fan-out with per-listener isolation (one throwing listener never blocks the rest). */
131
+ private static notifyIdentityChanged;
132
+ /** Build `getItemAsync`/`deleteItemAsync` options for a given keychain service (read/delete). */
133
+ private static _slotOpts;
134
+ /** Build private-key write options (device-only accessibility) for a given keychain service. */
135
+ private static _privateWriteOpts;
136
+ /** True only when both keys are present, well-formed, AND the public derives from the private. */
137
+ private static _isHealthyPair;
138
+ /**
139
+ * Resolve the AsyncStorage-backed KV store for the advisory migration flag, or
140
+ * `null` off-RN / when unavailable. Independent of the keychain, so the flag
141
+ * cannot be taken down by the keystore event this whole subsystem defends
142
+ * against.
143
+ */
144
+ private static _advisoryStorage;
145
+ private static _readSlotsMigratedFlag;
146
+ private static _setSlotsMigratedFlag;
147
+ /**
148
+ * Ensure the identity has been migrated onto the isolated v2 slots (or that we
149
+ * know we must read legacy this session). Memoized so concurrent callers share
150
+ * ONE run; a `deferred` (read-threw) outcome is not cached so a later call
151
+ * retries after the keychain unlocks. Every identity-slot accessor awaits this
152
+ * before touching storage.
153
+ */
154
+ private static _ensureIdentitySlotsMigrated;
155
+ /**
156
+ * One-shot slot migration state machine. All reads are DIRECT and a thrown
157
+ * read defers everything (zero writes/deletes) so a locked keychain is never
158
+ * mistaken for an empty one. INVARIANT: at every instant ≥1 readable copy of a
159
+ * previously-existing identity remains — legacy is deleted ONLY after the v2
160
+ * copy is verified re-readable in its new (non-aliasable) location.
161
+ */
162
+ private static _runSlotMigration;
163
+ /**
164
+ * Seed the v2 backup slot during migration. Prefers a healthy legacy backup;
165
+ * otherwise mirrors the (already-verified) v2 primary material so a v2 backup
166
+ * always exists on an independent keychain key. Best-effort — a failure just
167
+ * defers backup population to the next {@link _persistIdentityAtomic}.
168
+ */
169
+ private static _migrateBackupSlotToV2;
170
+ /** Best-effort single delete under an optional keychain service. Cleanup only — never surfaces. */
171
+ private static _bestEffortDelete;
172
+ private static _bestEffortDeleteV2Primary;
173
+ private static _bestEffortDeleteLegacyPrimaryAndBackup;
174
+ private static _bestEffortDeleteBackupsAllGenerations;
175
+ /**
176
+ * Clear the cross-app shared identity slot (force-delete only) so a deleted
177
+ * identity cannot be resurrected via the recovery ladder's shared rung.
178
+ * Best-effort — the shared slot is a redundant convenience copy.
179
+ */
180
+ private static _clearSharedSlot;
47
181
  /**
48
182
  * Invalidate cached shared identity state
49
183
  * Called internally when shared identity is created/deleted/imported
@@ -186,6 +320,15 @@ export declare class KeyManager {
186
320
  * @internal
187
321
  */
188
322
  private static _persistIdentityAtomic;
323
+ /**
324
+ * Write/refresh the identity marker after a successful persist. A same-identity
325
+ * re-persist (e.g. backup refresh, idempotent re-import) preserves `createdAt`
326
+ * and the `onboardingComplete` milestone by only updating `origin`; a NEW or
327
+ * switched identity writes a fresh marker. Best-effort — never throws.
328
+ *
329
+ * @internal
330
+ */
331
+ private static _syncMarkerAfterPersist;
189
332
  /**
190
333
  * Restore the backup slot to a previously-snapshotted state. Best-effort so
191
334
  * the original persistence error remains the one surfaced to the caller.
@@ -215,6 +358,15 @@ export declare class KeyManager {
215
358
  static createIdentity(options?: {
216
359
  overwrite?: boolean;
217
360
  }): Promise<string>;
361
+ /**
362
+ * Read the primary key pair DIRECTLY from storage, bypassing the in-memory
363
+ * cache (which a prior transient failure could have poisoned). Awaits slot
364
+ * migration first. Throws {@link IdentityUnavailableError} if storage is
365
+ * deferred/locked or a read throws — so overwrite guards never write blind.
366
+ *
367
+ * @internal
368
+ */
369
+ private static _readPrimaryDirect;
218
370
  /**
219
371
  * Import an existing key pair (e.g., from recovery phrase).
220
372
  *
@@ -229,10 +381,19 @@ export declare class KeyManager {
229
381
  /**
230
382
  * Get the stored private key
231
383
  * WARNING: Only use this for signing operations within the app
384
+ *
385
+ * Preserves the "return null on any storage failure" contract signing paths
386
+ * rely on (a locked keychain simply means "cannot sign now"); unlike
387
+ * {@link getPublicKey}, it does NOT throw {@link IdentityUnavailableError}.
232
388
  */
233
389
  static getPrivateKey(): Promise<string | null>;
234
390
  /**
235
- * Get the stored public key (cached for performance)
391
+ * Get the stored public key (cached for performance).
392
+ *
393
+ * Returns the public key, or `null` when a read SUCCEEDS and finds none.
394
+ * THROWS {@link IdentityUnavailableError} when storage is unreadable (keychain
395
+ * locked / module load failure) — a thrown read is NEVER flattened to `null`
396
+ * and NEVER cached, so a poisoned "no identity" verdict can no longer stick.
236
397
  */
237
398
  static getPublicKey(): Promise<string | null>;
238
399
  /**
@@ -240,13 +401,35 @@ export declare class KeyManager {
240
401
  *
241
402
  * Returns `true` only when BOTH the private and public keys are present,
242
403
  * both are well-formed, AND the public key derives from the private key.
243
- * A partially-written or corrupted identity returns `false` so that
244
- * downstream code can resume the create / restore flow correctly.
404
+ * A partially-written or corrupted identity (read succeeded, bytes empty/bad)
405
+ * returns `false` so that downstream code can resume the create / restore flow.
406
+ * THROWS {@link IdentityUnavailableError} when storage is unreadable — a locked
407
+ * keychain must never be mistaken for "no identity" (the old behavior that let
408
+ * onboarding treat a transient lock as a blank device).
245
409
  *
246
410
  * Note: this does NOT perform the full sign/verify roundtrip — call
247
411
  * `verifyIdentityIntegrity()` for that.
248
412
  */
249
413
  static hasIdentity(): Promise<boolean>;
414
+ /**
415
+ * Authoritative identity verdict — the corruption-vs-fresh-install
416
+ * disambiguator that routing (commons) keys off of.
417
+ *
418
+ * - Healthy pair → `present` (and the marker is backfilled if missing or
419
+ * pointing at a different key, `origin: 'backfill'`).
420
+ * - Read succeeded but no healthy pair, WITH a marker → `lost` (keystore death
421
+ * / corruption; route to recovery, NEVER to create).
422
+ * - Read succeeded, no pair, NO marker → `absent` (a genuine fresh device; the
423
+ * only state that may route to onboarding/create).
424
+ * - A read THREW → `unavailable` (keychain locked); this verdict is NEVER
425
+ * cached, so a later call re-reads.
426
+ *
427
+ * @param opts.bypassCache When true, never reads OR writes the in-memory cache
428
+ * — a pure, fresh storage verdict for the auto-create interlock preflight.
429
+ */
430
+ static getIdentityStatus(opts?: {
431
+ bypassCache?: boolean;
432
+ }): Promise<IdentityStatus>;
250
433
  /**
251
434
  * Delete the stored identity (both keys)
252
435
  * Use with EXTREME caution - this is irreversible without a recovery phrase
@@ -290,6 +473,32 @@ export declare class KeyManager {
290
473
  * conflicting key material is present) do we rebuild it from the backup.
291
474
  */
292
475
  static restoreIdentityFromBackup(): Promise<boolean>;
476
+ /**
477
+ * Recovery ladder — restore a `lost` identity from an independent,
478
+ * `key_v1`-surviving source WITHOUT the user re-entering their recovery phrase.
479
+ *
480
+ * Gated on {@link getIdentityStatus} being `lost` (marker present, keys empty):
481
+ * - `present` / `absent` → `not-lost` (nothing to recover / nothing lost)
482
+ * - `unavailable` → `unavailable` (keychain locked; retry later)
483
+ *
484
+ * Rungs, tried in order, each fully validated (well-formed + derive-match +
485
+ * `publicKey === marker.publicKey`, so a source holding a DIFFERENT account is
486
+ * SKIPPED, never restored):
487
+ * 1. the v2 backup slot (independent keychain key from the primary), then
488
+ * 2. the cross-app shared slot (Android bridge `getShared` / iOS keychain
489
+ * group) — the copy that survives a primary+backup `key_v1` death.
490
+ *
491
+ * On success it re-persists via {@link _persistIdentityAtomic} (origin
492
+ * `'restore'`) and invalidates the cache so routing re-reads `present`. When no
493
+ * rung matches, the UI proceeds to recovery-phrase entry.
494
+ */
495
+ static attemptIdentityRecovery(): Promise<IdentityRecoveryResult>;
496
+ /** Read the active-layout backup slot as a healthy candidate, or null. @internal */
497
+ private static _readBackupCandidate;
498
+ /** Read the cross-app shared slot as a healthy candidate, or null. @internal */
499
+ private static _readSharedCandidate;
500
+ /** Persist a validated recovery candidate + refresh caches/subscribers. @internal */
501
+ private static _commitRecovery;
293
502
  /**
294
503
  * Get the elliptic curve key object from the stored private key
295
504
  * Used internally for signing operations
@@ -47,8 +47,10 @@ export { SessionSyncRequiredError, AuthenticationFailedError, ensureValidToken,
47
47
  export type { HandleApiErrorOptions } from './utils/authHelpers';
48
48
  export { mergeSessions, normalizeAndSortSessions, sessionsArraysEqual, } from './utils/sessionUtils';
49
49
  export type { ClientSession, StorageKeys, MinimalUserData, SessionLoginResponse, } from './models/session';
50
- export { KeyManager, IdentityAlreadyExistsError, IdentityPersistError, } from './crypto/keyManager';
51
- export type { KeyPair } from './crypto/keyManager';
50
+ export { KeyManager, IdentityAlreadyExistsError, IdentityPersistError, IdentityUnavailableError, } from './crypto/keyManager';
51
+ export type { KeyPair, IdentityStatus, IdentityRecoveryResult } from './crypto/keyManager';
52
+ export { readIdentityMarker, updateIdentityMarker, } from './crypto/identityMarker';
53
+ export type { IdentityMarker } from './crypto/identityMarker';
52
54
  export { SignatureService } from './crypto/signatureService';
53
55
  export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
54
56
  export { RecoveryPhraseService } from './crypto/recoveryPhrase';
@@ -258,8 +258,15 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
258
258
  * The client must sign this challenge with their private key
259
259
  *
260
260
  * @param publicKey - The user's public key
261
+ * @param requestOptions - Optional per-call transport overrides (`retry`,
262
+ * `timeout`). Interactive callers omit it (defaults keep retries); the
263
+ * cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
264
+ * network cannot multiply boot latency via the inner retry loop.
261
265
  */
262
- requestChallenge(publicKey: string): Promise<ChallengeResponse>;
266
+ requestChallenge(publicKey: string, requestOptions?: {
267
+ retry?: boolean;
268
+ timeout?: number;
269
+ }): Promise<ChallengeResponse>;
263
270
  /**
264
271
  * Verify a signed challenge and create a session
265
272
  *
@@ -269,8 +276,15 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
269
276
  * @param timestamp - Timestamp when the signature was created
270
277
  * @param deviceName - Optional device name
271
278
  * @param deviceFingerprint - Optional device fingerprint
279
+ * @param requestOptions - Optional per-call transport overrides (`retry`,
280
+ * `timeout`). Interactive callers omit it (defaults keep retries); the
281
+ * cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
282
+ * network cannot multiply boot latency via the inner retry loop.
272
283
  */
273
- verifyChallenge(publicKey: string, challenge: string, signature: string, timestamp: number, deviceName?: string, deviceFingerprint?: string): Promise<SessionLoginResponse>;
284
+ verifyChallenge(publicKey: string, challenge: string, signature: string, timestamp: number, deviceName?: string, deviceFingerprint?: string, requestOptions?: {
285
+ retry?: boolean;
286
+ timeout?: number;
287
+ }): Promise<SessionLoginResponse>;
274
288
  /**
275
289
  * Check if a public key is already registered
276
290
  */
@@ -333,10 +347,21 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
333
347
  *
334
348
  * The cold-boot wiring that CALLS this lives in `OxyContext`
335
349
  * (`@oxyhq/services`); this method just performs the exchange.
350
+ *
351
+ * @param opts.requestOptions - Optional per-call transport overrides
352
+ * (`retry`, `timeout`) forwarded to BOTH the `requestChallenge` and
353
+ * `verifyChallenge` round-trips. Interactive flows omit it (defaults keep
354
+ * retries); the cold-boot `shared-key-signin` step passes `{ retry: false }`
355
+ * so this network step cannot multiply boot latency via the inner retry
356
+ * loop. The token-refresh scheduler / 401 lane still retry later.
336
357
  */
337
358
  signInWithSharedIdentity(opts?: {
338
359
  deviceName?: string;
339
360
  deviceFingerprint?: string;
361
+ requestOptions?: {
362
+ retry?: boolean;
363
+ timeout?: number;
364
+ };
340
365
  }): Promise<SessionLoginResponse | null>;
341
366
  /**
342
367
  * MECHANISM B (relying party) — begin a "Sign in with Oxy" handoff.
@@ -30,6 +30,14 @@ export declare function OxyServicesDeviceBootMixin<T extends typeof OxyServicesB
30
30
  * `no_active_session`) to decide whether to drop the secret and fall back or
31
31
  * resolve signed-out.
32
32
  *
33
+ * `retry: false`: the mint is a single logical attempt. The proactive
34
+ * token-refresh scheduler and the reactive 401 lane already own backoff and
35
+ * re-arm, so `HttpService`'s inner retry loop here would only multiply the
36
+ * mint's latency on a slow/black-hole network (3 retries × 5s timeout ≈ 20s
37
+ * per lane) with no correctness benefit — it is the dominant term in the cold
38
+ * boot's worst-case time-to-route. A transient failure surfaces once and the
39
+ * scheduler/401 path retries it later.
40
+ *
33
41
  * @throws if the response does not match {@link deviceTokenMintResponseSchema}.
34
42
  */
35
43
  mintFromDeviceSecret(deviceId: string, deviceSecret: string): Promise<DeviceTokenMintResponse>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "12.8.0",
3
+ "version": "12.9.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -455,3 +455,116 @@ describe('runSessionColdBoot — signed out', () => {
455
455
  expect(setTokens).not.toHaveBeenCalled();
456
456
  });
457
457
  });
458
+
459
+ describe('runSessionColdBoot — offline gating (isOffline)', () => {
460
+ /** Comfortably beyond the 60s refresh lead window. */
461
+ const farFuture = () => new Date(Date.now() + 3_600_000).toISOString();
462
+
463
+ const sharedSession: SessionLoginResponse = {
464
+ sessionId: 'sess-shared',
465
+ deviceId: 'dev-1',
466
+ expiresAt: '2030-01-01T00:00:00.000Z',
467
+ user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
468
+ accessToken: 'access-shared',
469
+ };
470
+
471
+ it('offline: skips BOTH network steps (no mint, no shared-key) → signed out', async () => {
472
+ // Credential present, no warm token — the ONLY things that could resolve are
473
+ // the two network steps, which the offline hint must gate off.
474
+ const { store, seed } = seedCredStore();
475
+ await seed();
476
+ const mintFromDeviceSecret = jest.fn(async () => MINT);
477
+ const signInWithSharedIdentity = jest.fn(async () => sharedSession);
478
+ const { oxy } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
479
+ const onSignedOut = jest.fn();
480
+
481
+ const outcome = await runSessionColdBoot({
482
+ oxy,
483
+ store,
484
+ platform: NATIVE,
485
+ isOffline: () => true,
486
+ onSignedOut,
487
+ });
488
+
489
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
490
+ expect(mintFromDeviceSecret).not.toHaveBeenCalled();
491
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
492
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
493
+ });
494
+
495
+ it('offline: the pure-local warm-token-plant STILL runs (returning user boots authenticated offline)', async () => {
496
+ const { store, seed } = seedCredStore({ accessToken: 'warm-access', expiresAt: farFuture() });
497
+ await seed();
498
+ const mintFromDeviceSecret = jest.fn(async () => MINT);
499
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
500
+
501
+ const outcome = await runSessionColdBoot({
502
+ oxy,
503
+ store,
504
+ platform: NATIVE,
505
+ isOffline: () => true,
506
+ });
507
+
508
+ // Warm plant is a pure-local read — never gated by the offline hint.
509
+ expect(outcome).toMatchObject({ kind: 'session', via: 'warm-token-plant' });
510
+ expect(setTokens).toHaveBeenCalledWith('warm-access');
511
+ expect(mintFromDeviceSecret).not.toHaveBeenCalled();
512
+ });
513
+
514
+ it('online (isOffline:()=>false): the network mint runs as normal', async () => {
515
+ const { store, seed } = seedCredStore();
516
+ await seed();
517
+ const mintFromDeviceSecret = jest.fn(async () => MINT);
518
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
519
+
520
+ const outcome = await runSessionColdBoot({
521
+ oxy,
522
+ store,
523
+ platform: WEB,
524
+ isOffline: () => false,
525
+ });
526
+
527
+ expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
528
+ expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
529
+ });
530
+
531
+ it('shared-key-signin passes { requestOptions: { retry: false } } (cold-boot single-attempt)', async () => {
532
+ const store = createMemoryAuthStateStore(); // no mint credential → mint step no-secret skip
533
+ const signInWithSharedIdentity = jest.fn(async () => sharedSession);
534
+ const { oxy } = makeOxy({ signInWithSharedIdentity });
535
+
536
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
537
+
538
+ expect(outcome).toMatchObject({ kind: 'session', via: 'shared-key-signin' });
539
+ expect(signInWithSharedIdentity).toHaveBeenCalledWith({ requestOptions: { retry: false } });
540
+ });
541
+ });
542
+
543
+ describe('runSessionColdBoot — overall deadline (overallDeadlineMs + onStepDeadline)', () => {
544
+ it('forwards the deadline: a non-settling mint step is abandoned via onStepDeadline and the boot ends bounded', async () => {
545
+ const { store, seed } = seedCredStore();
546
+ await seed();
547
+ // A mint that NEVER settles — without the overall deadline this hangs the
548
+ // whole boot (and therefore app routing) forever.
549
+ const mintFromDeviceSecret = jest.fn(
550
+ () => new Promise<DeviceTokenMintResponse>(() => undefined),
551
+ );
552
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
553
+ const onStepDeadline = jest.fn();
554
+ const onSignedOut = jest.fn();
555
+
556
+ const outcome = await runSessionColdBoot({
557
+ oxy,
558
+ store,
559
+ platform: WEB,
560
+ overallDeadlineMs: 50,
561
+ onStepDeadline,
562
+ onSignedOut,
563
+ });
564
+
565
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
566
+ expect(onStepDeadline).toHaveBeenCalledWith('device-secret-mint');
567
+ // A deadline trip is not an error — the boot resolves signed-out, not `error`.
568
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
569
+ });
570
+ });