@oxyhq/core 12.7.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 (56) 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/cjs/mixins/OxyServices.utility.js +11 -1
  9. package/dist/cjs/server/auth.js +3 -0
  10. package/dist/cjs/server/index.js +2 -1
  11. package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
  12. package/dist/esm/.tsbuildinfo +1 -1
  13. package/dist/esm/boot/sessionColdBoot.js +16 -3
  14. package/dist/esm/crypto/identityMarker.js +248 -0
  15. package/dist/esm/crypto/keyManager.js +843 -106
  16. package/dist/esm/index.js +2 -1
  17. package/dist/esm/mixins/OxyServices.auth.js +21 -6
  18. package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
  19. package/dist/esm/mixins/OxyServices.utility.js +11 -1
  20. package/dist/esm/server/auth.js +2 -0
  21. package/dist/esm/server/index.js +1 -1
  22. package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
  23. package/dist/types/.tsbuildinfo +1 -1
  24. package/dist/types/boot/sessionColdBoot.d.ts +25 -0
  25. package/dist/types/crypto/identityMarker.d.ts +94 -0
  26. package/dist/types/crypto/keyManager.d.ts +212 -3
  27. package/dist/types/index.d.ts +4 -2
  28. package/dist/types/mixins/OxyServices.auth.d.ts +27 -2
  29. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
  30. package/dist/types/mixins/OxyServices.utility.d.ts +3 -0
  31. package/dist/types/server/auth.d.ts +4 -0
  32. package/dist/types/server/index.d.ts +2 -2
  33. package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -0
  34. package/package.json +1 -1
  35. package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
  36. package/src/boot/sessionColdBoot.ts +42 -3
  37. package/src/crypto/__tests__/identityMocks.ts +125 -0
  38. package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
  39. package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
  40. package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
  41. package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
  42. package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
  43. package/src/crypto/__tests__/keyManager.test.ts +77 -87
  44. package/src/crypto/identityMarker.ts +291 -0
  45. package/src/crypto/keyManager.ts +1026 -105
  46. package/src/index.ts +7 -1
  47. package/src/mixins/OxyServices.auth.ts +31 -7
  48. package/src/mixins/OxyServices.deviceBoot.ts +9 -1
  49. package/src/mixins/OxyServices.utility.ts +19 -1
  50. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
  51. package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
  52. package/src/mixins/__tests__/serviceAuth.test.ts +65 -0
  53. package/src/server/auth.ts +5 -0
  54. package/src/server/index.ts +2 -0
  55. package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
  56. package/src/utils/oxyServiceEnvironment.ts +17 -0
@@ -35,6 +35,10 @@ const refresh_1 = require("../session/refresh");
35
35
  async function runSessionColdBoot(opts) {
36
36
  const { oxy, store } = opts;
37
37
  const isNative = opts.platform?.isNative ?? (0, platform_1.isNative)();
38
+ // Best-effort connectivity gate for the NETWORK steps only. A missing hint or
39
+ // any non-`true` verdict means "assume online" — never falsely skip a real
40
+ // sign-in on an ambiguous probe.
41
+ const isOffline = () => opts.isOffline?.() ?? false;
38
42
  // Boot-local (not module-level) so it cannot leak across boots or break under
39
43
  // bundler re-evaluation.
40
44
  let signedOutReason = 'no_session';
@@ -86,6 +90,9 @@ async function runSessionColdBoot(opts) {
86
90
  // the durable store always converges on the true `current` secret.
87
91
  steps.push({
88
92
  id: 'device-secret-mint',
93
+ // Network step — skip entirely when the caller reports the device offline so
94
+ // a doomed mint cannot burn the overall deadline before routing settles.
95
+ enabled: () => !isOffline(),
89
96
  run: async () => {
90
97
  const result = await (0, refresh_1.refreshDeviceSecretArm)({ oxy, store });
91
98
  switch (result.status) {
@@ -130,12 +137,16 @@ async function runSessionColdBoot(opts) {
130
137
  }
131
138
  },
132
139
  });
133
- // 3. shared-key-signin (native) — re-mint from the shared identity.
140
+ // 3. shared-key-signin (native) — re-mint from the shared identity. Native
141
+ // AND online: it is a network step (challenge + verify round-trips), so it
142
+ // is gated by the same offline hint as the mint lane. `{ retry: false }`
143
+ // keeps the two round-trips as single attempts — the refresh scheduler /
144
+ // 401 lane own later retries — so this step cannot multiply boot latency.
134
145
  steps.push({
135
146
  id: 'shared-key-signin',
136
- enabled: () => isNative,
147
+ enabled: () => isNative && !isOffline(),
137
148
  run: async () => {
138
- const session = await oxy.signInWithSharedIdentity();
149
+ const session = await oxy.signInWithSharedIdentity({ requestOptions: { retry: false } });
139
150
  if (!session?.accessToken) {
140
151
  return { kind: 'skip' };
141
152
  }
@@ -164,6 +175,8 @@ async function runSessionColdBoot(opts) {
164
175
  });
165
176
  const outcome = await (0, coldBoot_1.runColdBoot)({
166
177
  steps,
178
+ overallDeadlineMs: opts.overallDeadlineMs,
179
+ onStepDeadline: opts.onStepDeadline,
167
180
  onStepError: (id, error) => {
168
181
  signedOutReason = 'error';
169
182
  opts.onStepError?.(id, error);
@@ -0,0 +1,255 @@
1
+ "use strict";
2
+ /**
3
+ * Identity marker — a NON-secret, AndroidKeyStore-independent record that an
4
+ * identity exists (or existed) on this device.
5
+ *
6
+ * WHY THIS EXISTS: the identity private/public keys live in expo-secure-store,
7
+ * whose Android backing (a single `key_v1` AndroidKeyStore key by default) can be
8
+ * invalidated by an OS/vendor keystore event. When that happens SDK 57's
9
+ * expo-secure-store DELETES the undecryptable ciphertext on the read path and
10
+ * returns `null` — indistinguishable, from the keys alone, from a genuinely
11
+ * fresh install. That ambiguity is what lets a real identity get silently
12
+ * replaced by the onboarding "create" flow.
13
+ *
14
+ * The marker breaks the tie. It is written to AsyncStorage (RN) / localStorage
15
+ * (web) — storage that is NOT protected by the identity's AndroidKeyStore key —
16
+ * so it SURVIVES a keystore death. `getIdentityStatus()` reads it: keys empty +
17
+ * marker present ⇒ `lost` (route to recovery, NEVER welcome/create); keys empty
18
+ * + no marker ⇒ `absent` (the only path to fresh onboarding).
19
+ *
20
+ * It holds only the PUBLIC key plus provenance metadata — never any secret — so
21
+ * persisting it in plain KV storage adds no exposure.
22
+ *
23
+ * Every operation fails OPEN (returns null / false / resolves): the marker is a
24
+ * best-effort disambiguation signal layered on top of the authoritative
25
+ * secure-store reads, never a gate that can itself lock the user out.
26
+ *
27
+ * ESM-safe (no `require()`); zero React/RN static imports — the RN AsyncStorage
28
+ * module is reached only through `@oxyhq/protocol`'s per-platform dynamic loader.
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.IDENTITY_MARKER_STORAGE_KEY = void 0;
32
+ exports.readIdentityMarker = readIdentityMarker;
33
+ exports.writeIdentityMarker = writeIdentityMarker;
34
+ exports.updateIdentityMarker = updateIdentityMarker;
35
+ exports.clearIdentityMarker = clearIdentityMarker;
36
+ const protocol_1 = require("@oxyhq/protocol");
37
+ const logger_1 = require("../logger");
38
+ const log = (0, logger_1.createLogger)('IdentityMarker');
39
+ /**
40
+ * AsyncStorage / localStorage key holding the serialized {@link IdentityMarker}.
41
+ * `.v1` lets a future shape change ship a `.v2` key without misreading a stale
42
+ * blob. Distinct from every `oxy_identity_*` secure-store key so it never
43
+ * collides with the keychain material it disambiguates.
44
+ */
45
+ exports.IDENTITY_MARKER_STORAGE_KEY = 'oxy_identity_marker_v1';
46
+ /** RN detection identical to `DeviceManager` — chooses AsyncStorage vs localStorage. */
47
+ function isReactNative() {
48
+ return typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
49
+ }
50
+ /**
51
+ * Resolve the platform KV store, or `null` when none is reachable (SSR, a
52
+ * sandboxed iframe whose `localStorage` getter throws, AsyncStorage not linked).
53
+ * A `null` store makes every marker operation a no-op that fails open.
54
+ */
55
+ async function getStorage() {
56
+ try {
57
+ if (isReactNative()) {
58
+ // `loadAsyncStorage` is per-platform: the RN variant statically imports
59
+ // @react-native-async-storage/async-storage; the default variant throws
60
+ // (never reached here because of the `isReactNative()` gate).
61
+ const asyncStorageModule = await (0, protocol_1.loadAsyncStorage)();
62
+ const storage = asyncStorageModule.default;
63
+ return {
64
+ getItem: storage.getItem.bind(storage),
65
+ setItem: storage.setItem.bind(storage),
66
+ removeItem: storage.removeItem.bind(storage),
67
+ };
68
+ }
69
+ // Web: read `localStorage` through a try — merely ACCESSING it can throw a
70
+ // `SecurityError` in a sandboxed/cross-origin iframe.
71
+ if (typeof globalThis !== 'undefined') {
72
+ const ls = globalThis.localStorage;
73
+ if (ls) {
74
+ return {
75
+ getItem: async (key) => ls.getItem(key),
76
+ setItem: async (key, value) => {
77
+ ls.setItem(key, value);
78
+ },
79
+ removeItem: async (key) => {
80
+ ls.removeItem(key);
81
+ },
82
+ };
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+ catch (error) {
88
+ log.warn('Identity marker storage is unavailable', undefined, error);
89
+ return null;
90
+ }
91
+ }
92
+ /**
93
+ * Parse + shape-validate a stored blob. Returns `null` for anything that is not
94
+ * a well-formed {@link IdentityMarker} so a corrupt/foreign entry degrades to
95
+ * "no marker" rather than throwing.
96
+ */
97
+ function deserialize(raw) {
98
+ if (!raw) {
99
+ return null;
100
+ }
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(raw);
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ if (!parsed || typeof parsed !== 'object') {
109
+ return null;
110
+ }
111
+ const candidate = parsed;
112
+ if (candidate.v !== 1) {
113
+ return null;
114
+ }
115
+ if (typeof candidate.publicKey !== 'string' || candidate.publicKey.length === 0) {
116
+ return null;
117
+ }
118
+ if (typeof candidate.createdAt !== 'number' || !Number.isFinite(candidate.createdAt)) {
119
+ return null;
120
+ }
121
+ const origin = candidate.origin;
122
+ if (origin !== 'create' && origin !== 'import' && origin !== 'restore' && origin !== 'backfill') {
123
+ return null;
124
+ }
125
+ const marker = {
126
+ v: 1,
127
+ publicKey: candidate.publicKey,
128
+ createdAt: candidate.createdAt,
129
+ origin,
130
+ };
131
+ if (typeof candidate.onboardingComplete === 'boolean') {
132
+ marker.onboardingComplete = candidate.onboardingComplete;
133
+ }
134
+ return marker;
135
+ }
136
+ /**
137
+ * Read the identity marker. Fails OPEN: returns `null` on any storage error,
138
+ * missing entry, or malformed blob — the caller treats "no marker" as the safe
139
+ * default (fresh install), and the authoritative secure-store read decides the
140
+ * rest.
141
+ */
142
+ async function readIdentityMarker() {
143
+ const storage = await getStorage();
144
+ if (!storage) {
145
+ return null;
146
+ }
147
+ try {
148
+ return deserialize(await storage.getItem(exports.IDENTITY_MARKER_STORAGE_KEY));
149
+ }
150
+ catch (error) {
151
+ log.warn('Failed to read identity marker', undefined, error);
152
+ return null;
153
+ }
154
+ }
155
+ /**
156
+ * Write (create/replace) the marker. Returns `true` when it durably landed,
157
+ * `false` when storage was unavailable or the write threw. Callers treat a
158
+ * `false` as non-fatal — the marker is best-effort and a subsequent read
159
+ * re-backfills it from the healthy key pair.
160
+ */
161
+ async function writeIdentityMarker(input) {
162
+ const storage = await getStorage();
163
+ if (!storage) {
164
+ return false;
165
+ }
166
+ const marker = {
167
+ v: 1,
168
+ publicKey: input.publicKey,
169
+ createdAt: input.createdAt ?? Date.now(),
170
+ origin: input.origin,
171
+ };
172
+ if (typeof input.onboardingComplete === 'boolean') {
173
+ marker.onboardingComplete = input.onboardingComplete;
174
+ }
175
+ try {
176
+ await storage.setItem(exports.IDENTITY_MARKER_STORAGE_KEY, JSON.stringify(marker));
177
+ return true;
178
+ }
179
+ catch (error) {
180
+ log.warn('Failed to write identity marker', undefined, error);
181
+ return false;
182
+ }
183
+ }
184
+ /**
185
+ * Merge a partial update into the existing marker, preserving every field the
186
+ * caller does not override (notably `createdAt` and `onboardingComplete`). When
187
+ * no marker exists yet, a partial carrying at least `publicKey` + `origin`
188
+ * creates one; otherwise the update is a no-op returning `false`.
189
+ *
190
+ * Used to (a) mirror the onboarding milestone (`{ onboardingComplete: true }`)
191
+ * without disturbing provenance, and (b) refresh `origin` on a same-identity
192
+ * re-persist without resetting `createdAt`.
193
+ */
194
+ async function updateIdentityMarker(partial) {
195
+ const storage = await getStorage();
196
+ if (!storage) {
197
+ return false;
198
+ }
199
+ let existing = null;
200
+ try {
201
+ existing = deserialize(await storage.getItem(exports.IDENTITY_MARKER_STORAGE_KEY));
202
+ }
203
+ catch (error) {
204
+ log.warn('Failed to read identity marker before update', undefined, error);
205
+ existing = null;
206
+ }
207
+ if (!existing) {
208
+ if (typeof partial.publicKey === 'string' && partial.publicKey.length > 0 && partial.origin) {
209
+ return writeIdentityMarker({
210
+ publicKey: partial.publicKey,
211
+ origin: partial.origin,
212
+ createdAt: partial.createdAt,
213
+ onboardingComplete: partial.onboardingComplete,
214
+ });
215
+ }
216
+ return false;
217
+ }
218
+ const nextOnboarding = partial.onboardingComplete !== undefined ? partial.onboardingComplete : existing.onboardingComplete;
219
+ const next = {
220
+ v: 1,
221
+ publicKey: partial.publicKey ?? existing.publicKey,
222
+ createdAt: partial.createdAt ?? existing.createdAt,
223
+ origin: partial.origin ?? existing.origin,
224
+ };
225
+ if (typeof nextOnboarding === 'boolean') {
226
+ next.onboardingComplete = nextOnboarding;
227
+ }
228
+ try {
229
+ await storage.setItem(exports.IDENTITY_MARKER_STORAGE_KEY, JSON.stringify(next));
230
+ return true;
231
+ }
232
+ catch (error) {
233
+ log.warn('Failed to update identity marker', undefined, error);
234
+ return false;
235
+ }
236
+ }
237
+ /**
238
+ * Remove the marker. Called ONLY after the identity's keys have been
239
+ * successfully deleted (`KeyManager.deleteIdentity`), so a marker never outlives
240
+ * the identity it records. Fails open (swallows errors) — a leftover marker
241
+ * simply routes a truly-absent identity to `recovery` instead of `welcome`,
242
+ * which is the safe direction.
243
+ */
244
+ async function clearIdentityMarker() {
245
+ const storage = await getStorage();
246
+ if (!storage) {
247
+ return;
248
+ }
249
+ try {
250
+ await storage.removeItem(exports.IDENTITY_MARKER_STORAGE_KEY);
251
+ }
252
+ catch (error) {
253
+ log.warn('Failed to clear identity marker', undefined, error);
254
+ }
255
+ }