edge-core-js 2.47.1 → 2.48.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.
@@ -512,6 +512,10 @@ export function makeContextApi(ai) {
512
512
  async changeLogSettings(settings) {
513
513
  const newSettings = { ...ai.props.state.logSettings, ...settings }
514
514
  ai.props.dispatch({ type: 'CHANGE_LOG_SETTINGS', payload: newSettings })
515
+ },
516
+
517
+ async setAttestationToken(token) {
518
+ ai.props.dispatch({ type: 'SET_ATTESTATION_TOKEN', payload: token })
515
519
  }
516
520
  }
517
521
  bridgifyObject(out)
@@ -22,7 +22,6 @@ import { asLoginPayload } from '../../types/server-cleaners'
22
22
 
23
23
 
24
24
  import { base58 } from '../../util/encoding'
25
- import { validateServer } from '../../util/validateServer'
26
25
 
27
26
  import { applyLoginPayload } from '../login/login'
28
27
  import { wasLoginStash } from '../login/login-stash'
@@ -32,6 +31,20 @@ import { makeRepoPaths, saveChanges } from '../storage/repo'
32
31
  import { FakeDb } from './fake-db'
33
32
  import { makeFakeServer } from './fake-server'
34
33
 
34
+ /**
35
+ * Account infrastructure hosts that stay on the in-memory fake server when
36
+ * `allowNetworkAccess` is enabled. Change servers and everything else use
37
+ * real `io.fetch`.
38
+ */
39
+ function isFakeAccountInfrastructure(uri) {
40
+ try {
41
+ const { hostname } = new URL(uri)
42
+ return /^(login|info|sync)[a-z0-9-]*\.edge(test)?\.app$/i.test(hostname)
43
+ } catch (e) {
44
+ return false
45
+ }
46
+ }
47
+
35
48
  async function saveLogin(io, user) {
36
49
  const { lastLogin, server } = user
37
50
  const loginId = base64.parse(user.loginId)
@@ -114,12 +127,10 @@ export function makeFakeWorld(
114
127
  const fetch = !allowNetworkAccess
115
128
  ? fakeFetch
116
129
  : (uri, opts) => {
117
- try {
118
- validateServer(uri) // Throws for non-Edge servers.
119
- } catch (error) {
120
- return io.fetch(uri, opts)
130
+ if (isFakeAccountInfrastructure(uri)) {
131
+ return fakeFetch(uri, opts)
121
132
  }
122
- return fakeFetch(uri, opts)
133
+ return io.fetch(uri, opts)
123
134
  }
124
135
 
125
136
  const fakeIo = {
@@ -117,7 +117,7 @@ export function loginFetchInner(
117
117
  body
118
118
  ) {
119
119
  const { state, io, log } = ai.props
120
- const { apiKey, apiSecret } = state.login
120
+ const { apiKey, apiSecret, attestationToken } = state.login
121
121
 
122
122
  const bodyText =
123
123
  method === 'GET' || body == null
@@ -138,7 +138,10 @@ export function loginFetchInner(
138
138
  headers: {
139
139
  'content-type': 'application/json',
140
140
  accept: 'application/json',
141
- authorization
141
+ authorization,
142
+ ...(attestationToken != null
143
+ ? { 'x-attestation-token': attestationToken }
144
+ : {})
142
145
  },
143
146
  corsBypass: 'never'
144
147
  }
@@ -28,6 +28,7 @@ import { findPin2Stash } from './pin2'
28
28
 
29
29
 
30
30
 
31
+
31
32
  export const login = buildReducer({
32
33
  apiKey(state = '', action) {
33
34
  return action.type === 'INIT' ? action.payload.apiKey : state
@@ -37,6 +38,13 @@ export const login = buildReducer({
37
38
  return action.type === 'INIT' ? _nullishCoalesce(action.payload.apiSecret, () => ( null)) : state
38
39
  },
39
40
 
41
+ attestationToken(state = null, action) {
42
+ if (action.type !== 'SET_ATTESTATION_TOKEN') return state
43
+ const token = action.payload
44
+ // Treat empty string like clear so we never send x-attestation-token: ''.
45
+ return token == null || token === '' ? null : token
46
+ },
47
+
40
48
  contextAppId(state = '', action) {
41
49
  return action.type === 'INIT' ? action.payload.appId : state
42
50
  },
package/lib/node/index.js CHANGED
@@ -597,21 +597,6 @@ const utf8 = {
597
597
  }
598
598
  };
599
599
 
600
- /**
601
- * We only accept *.edge.app or localhost as valid domain names.
602
- */
603
- function validateServer(server) {
604
- const url = new URL(server);
605
- if (url.protocol === 'http:' || url.protocol === 'ws:') {
606
- if (url.hostname === 'localhost') return;
607
- }
608
- if (url.protocol === 'https:' || url.protocol === 'wss:') {
609
- if (url.hostname === 'localhost') return;
610
- if (/^([A-Za-z0-9_-]+\.)*edge(test)?\.app$/.test(url.hostname)) return;
611
- }
612
- throw new Error(`Only *.edge.app or localhost are valid login domain names, not ${url.hostname}`);
613
- }
614
-
615
600
  /*
616
601
  * These are errors the core knows about.
617
602
  *
@@ -1485,7 +1470,8 @@ function loginFetchInner(ai, serverUri, method, path, body) {
1485
1470
  } = ai.props;
1486
1471
  const {
1487
1472
  apiKey,
1488
- apiSecret
1473
+ apiSecret,
1474
+ attestationToken
1489
1475
  } = state.login;
1490
1476
  const bodyText = method === 'GET' || body == null ? undefined : JSON.stringify(wasLoginRequestBody(body));
1491
1477
 
@@ -1502,7 +1488,10 @@ function loginFetchInner(ai, serverUri, method, path, body) {
1502
1488
  headers: {
1503
1489
  'content-type': 'application/json',
1504
1490
  accept: 'application/json',
1505
- authorization
1491
+ authorization,
1492
+ ...(attestationToken != null ? {
1493
+ 'x-attestation-token': attestationToken
1494
+ } : {})
1506
1495
  },
1507
1496
  corsBypass: 'never'
1508
1497
  };
@@ -2447,6 +2436,49 @@ function makeAuthJson(stashTree, sessionKey) {
2447
2436
  throw new Error('No server authentication methods available');
2448
2437
  }
2449
2438
 
2439
+ /**
2440
+ * We only accept *.edge.app, localhost, or (for http/ws only) private LAN IPv4.
2441
+ * https/wss still require localhost or *.edge(test)?.app; private IPs are not
2442
+ * accepted on secure schemes.
2443
+ */
2444
+ function validateServer(server) {
2445
+ const url = new URL(server);
2446
+ if (url.protocol === 'http:' || url.protocol === 'ws:') {
2447
+ if (isPrivateHost(url.hostname)) return;
2448
+ }
2449
+ if (url.protocol === 'https:' || url.protocol === 'wss:') {
2450
+ if (url.hostname === 'localhost') return;
2451
+ if (/^([A-Za-z0-9_-]+\.)*edge(test)?\.app$/.test(url.hostname)) return;
2452
+ }
2453
+ throw new Error(`Only *.edge.app, localhost, or private LAN addresses (http/ws) are valid login domain names, not ${url.hostname}`);
2454
+ }
2455
+ function isPrivateHost(hostname) {
2456
+ if (hostname === 'localhost') return true;
2457
+ const octets = parseIpv4(hostname);
2458
+ if (octets == null) return false;
2459
+ const [a, b] = octets;
2460
+ if (a === 127) return true;
2461
+ if (a === 10) return true;
2462
+ if (a === 192 && b === 168) return true;
2463
+ if (a === 172 && b >= 16 && b <= 31) return true;
2464
+ return false;
2465
+ }
2466
+ function parseIpv4(hostname) {
2467
+ const parts = hostname.split('.');
2468
+ if (parts.length !== 4) return null;
2469
+ const octets = [];
2470
+ for (const part of parts) {
2471
+ if (!/^\d{1,3}$/.test(part)) return null;
2472
+ const n = Number(part);
2473
+ if (!Number.isInteger(n) || n < 0 || n > 255) return null;
2474
+ // Reject leading zeros like 010.0.0.1 which are not canonical dotted-quad
2475
+ // when they reach this helper (URL parsing may already rewrite some forms).
2476
+ if (part.length > 1 && part.startsWith('0')) return null;
2477
+ octets.push(n);
2478
+ }
2479
+ return octets;
2480
+ }
2481
+
2450
2482
  /**
2451
2483
  * A wrapper that knows how to load and save JSON files,
2452
2484
  * with parsing, stringifying, and cleaning.
@@ -11843,6 +11875,12 @@ function makeContextApi(ai) {
11843
11875
  type: 'CHANGE_LOG_SETTINGS',
11844
11876
  payload: newSettings
11845
11877
  });
11878
+ },
11879
+ async setAttestationToken(token) {
11880
+ ai.props.dispatch({
11881
+ type: 'SET_ATTESTATION_TOKEN',
11882
+ payload: token
11883
+ });
11846
11884
  }
11847
11885
  };
11848
11886
  yaob.bridgifyObject(out);
@@ -12815,6 +12853,12 @@ const login = reduxKeto.buildReducer({
12815
12853
  apiSecret(state = null, action) {
12816
12854
  return action.type === 'INIT' ? action.payload.apiSecret ?? null : state;
12817
12855
  },
12856
+ attestationToken(state = null, action) {
12857
+ if (action.type !== 'SET_ATTESTATION_TOKEN') return state;
12858
+ const token = action.payload;
12859
+ // Treat empty string like clear so we never send x-attestation-token: ''.
12860
+ return token == null || token === '' ? null : token;
12861
+ },
12818
12862
  contextAppId(state = '', action) {
12819
12863
  return action.type === 'INIT' ? action.payload.appId : state;
12820
12864
  },
@@ -14385,6 +14429,21 @@ function makeFakeServer(db) {
14385
14429
  return out;
14386
14430
  }
14387
14431
 
14432
+ /**
14433
+ * Account infrastructure hosts that stay on the in-memory fake server when
14434
+ * `allowNetworkAccess` is enabled. Change servers and everything else use
14435
+ * real `io.fetch`.
14436
+ */
14437
+ function isFakeAccountInfrastructure(uri) {
14438
+ try {
14439
+ const {
14440
+ hostname
14441
+ } = new URL(uri);
14442
+ return /^(login|info|sync)[a-z0-9-]*\.edge(test)?\.app$/i.test(hostname);
14443
+ } catch {
14444
+ return false;
14445
+ }
14446
+ }
14388
14447
  async function saveLogin(io, user) {
14389
14448
  const {
14390
14449
  lastLogin,
@@ -14452,12 +14511,10 @@ function makeFakeWorld(ios, logBackend, users) {
14452
14511
  } = opts;
14453
14512
  const fakeFetch = serverlet.makeFetchFunction(fakeServer);
14454
14513
  const fetch = !allowNetworkAccess ? fakeFetch : (uri, opts) => {
14455
- try {
14456
- validateServer(uri); // Throws for non-Edge servers.
14457
- } catch (error) {
14458
- return io.fetch(uri, opts);
14514
+ if (isFakeAccountInfrastructure(uri)) {
14515
+ return fakeFetch(uri, opts);
14459
14516
  }
14460
- return fakeFetch(uri, opts);
14517
+ return io.fetch(uri, opts);
14461
14518
  };
14462
14519
  const fakeIo = {
14463
14520
  ...io,
@@ -2239,6 +2239,13 @@ export * from './server-types'
2239
2239
 
2240
2240
 
2241
2241
 
2242
+
2243
+
2244
+
2245
+
2246
+
2247
+
2248
+
2242
2249
 
2243
2250
 
2244
2251
 
package/lib/util/nym.js CHANGED
@@ -32,54 +32,148 @@ export const mixFetchOptions = {
32
32
  */
33
33
  const SETUP_TIMEOUT_MS = 60000
34
34
 
35
- // MixFetch initialization state
36
- let mixFetchInitPromise = null
35
+ /**
36
+ * How long to refuse new setups after one fails, and the ceiling that wait
37
+ * doubles up to.
38
+ *
39
+ * Every `createMixFetch` spawns a web worker holding megabytes of WASM before
40
+ * it ever contacts the gateway, and the library exposes no way to terminate
41
+ * that worker, so a failed setup leaves one behind. Retrying on each request
42
+ * therefore costs memory per attempt: with a dead gateway and a poll loop
43
+ * driving requests every few seconds, the workers accumulate until the host
44
+ * kills the whole JS context. On iOS that reads to the user as being logged
45
+ * out, since the core's WebView is what gets killed and reloaded.
46
+ *
47
+ * A cooldown bounds the cost to one worker per window, and the doubling keeps
48
+ * a long outage from spending any meaningful memory at all.
49
+ */
50
+ const RETRY_BASE_MS = 30000
51
+ const RETRY_MAX_MS = 300000
37
52
 
38
53
  /**
39
- * Initialize the NYM mixFetch client. Must be called before using mixFetch.
40
- * Safe to call multiple times - subsequent calls return the same promise.
54
+ * Builds the mixFetch setup routine over its own cooldown state.
55
+ *
56
+ * The returned function initializes the NYM mixFetch client, and must be
57
+ * called before using mixFetch. It is safe to call multiple times: subsequent
58
+ * calls return the same promise.
59
+ *
60
+ * A failed setup starts a cooldown: calls made before it expires fail
61
+ * immediately with the error that started it, instead of building another
62
+ * client.
63
+ *
64
+ * Tests build their own instance over a clock they control, which also gives
65
+ * them fresh state per case.
66
+ *
67
+ * The cooldown state is per instance, but the client, its worker and
68
+ * `window.__mixFetchGlobal` are process-global, so two live instances would
69
+ * each hold a cooldown of their own while spawning workers into the same
70
+ * process. The single instance exported below is the only supported
71
+ * arrangement.
41
72
  */
42
- export async function initMixFetch(log) {
43
- if (mixFetchInitPromise == null) {
44
- log('Initializing mixFetch...')
45
- const pending = createMixFetch(mixFetchOptions)
46
- // The timeout below can abandon this setup while it is still in flight.
47
- // Deliberately do NOT tear it down on late completion: `createMixFetch`
48
- // resolves to a healthy global singleton, and disconnecting it (a
49
- // process-wide operation) would race a newer init that has taken over.
50
- // A late completion just repopulates `__mixFetchGlobal`, which the next
51
- // init reuses. Swallow a late rejection so it is not unhandled.
52
- pending.catch(() => {})
53
- let timer
54
- const timeout = new Promise((resolve, reject) => {
55
- timer = setTimeout(() => {
56
- reject(
57
- new Error(`mixFetch setup timed out after ${SETUP_TIMEOUT_MS}ms`)
73
+ export function makeMixFetchSetup(
74
+ now = () => Date.now()
75
+ ) {
76
+ let mixFetchInitPromise = null
77
+
78
+ /** When a new setup may be attempted, and the wait that produced it. */
79
+ let retryAfter = 0
80
+ let retryDelay = RETRY_BASE_MS
81
+
82
+ /** The failure to re-throw for callers that arrive during the cooldown. */
83
+ let lastError
84
+
85
+ return async function initMixFetch(log) {
86
+ if (mixFetchInitPromise == null) {
87
+ if (now() < retryAfter) {
88
+ // Re-throwing `lastError` itself would hand the caller a stack and a
89
+ // message from a setup that ended minutes ago, so a cooldown
90
+ // rejection reads in a crash report as a fresh 60-second timeout. It
91
+ // is also not necessarily an `Error`: the library rejects with a raw
92
+ // `MessageEvent` on a worker error, so `.message` can be undefined
93
+ // downstream.
94
+ //
95
+ // Nothing is logged here. This runs once per refused caller, and the
96
+ // breadcrumb that makes the quiet window visible is emitted once per
97
+ // window where the cooldown is armed.
98
+ const remainingMs = retryAfter - now()
99
+ const error = new Error(
100
+ `mixFetch setup is cooling down for another ${Math.round(
101
+ remainingMs / 1000
102
+ )}s`
58
103
  )
59
- }, SETUP_TIMEOUT_MS)
60
- })
61
- mixFetchInitPromise = Promise.race([pending, timeout])
62
- .then(mixFetchModule => {
63
- log('mixFetch initialized successfully')
64
- return mixFetchModule
65
- })
66
- .catch(async error => {
67
- // Clean up stale global state left by the failed init so the
68
- // next createMixFetch call starts fresh instead of reusing a
69
- // broken singleton.
70
- try {
71
- await disconnectMixFetch()
72
- } catch (e) {}
73
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
74
- delete (window ).__mixFetchGlobal
75
- mixFetchInitPromise = null
76
- log.error('mixFetch initialization failed:', error)
104
+ error.cause = lastError
77
105
  throw error
106
+ }
107
+
108
+ log('Initializing mixFetch...')
109
+ const pending = createMixFetch(mixFetchOptions)
110
+ // The timeout below can abandon this setup while it is still in flight.
111
+ // `createMixFetch` publishes `window.__mixFetchGlobal` as soon as the
112
+ // worker exists and only then awaits the gateway handshake
113
+ // (`@nymproject/mix-fetch/index.js:446-449`), so the failure path
114
+ // usually deletes a global whose owner is already past that assignment:
115
+ // the abandoned worker stays alive and unreachable, and the next setup
116
+ // builds a fresh one. A delete that lands before the assignment lets the
117
+ // late completion re-publish instead, and the line right after it
118
+ // configures that client, so the next setup finds a working global
119
+ // rather than an unconfigured one. Either way the cost is one worker per
120
+ // cooldown window, against a ceiling of RETRY_MAX_MS. Swallow the late
121
+ // rejection so it is not unhandled.
122
+ pending.catch(() => {})
123
+ let timer
124
+ const timeout = new Promise((resolve, reject) => {
125
+ timer = setTimeout(() => {
126
+ reject(
127
+ new Error(`mixFetch setup timed out after ${SETUP_TIMEOUT_MS}ms`)
128
+ )
129
+ }, SETUP_TIMEOUT_MS)
78
130
  })
79
- .finally(() => {
80
- clearTimeout(timer)
81
- })
131
+ mixFetchInitPromise = Promise.race([pending, timeout])
132
+ .then(mixFetchModule => {
133
+ log('mixFetch initialized successfully')
134
+ return mixFetchModule
135
+ })
136
+ .catch(error => {
137
+ // Arm the cooldown before any cleanup. The timeout fires while the
138
+ // setup is still running, so the disconnect below is an RPC into a
139
+ // worker that is still busy and has no bounded completion; awaiting
140
+ // it would leave `mixFetchInitPromise` pending forever, and every
141
+ // later caller would await that instead of failing fast.
142
+ mixFetchInitPromise = null
143
+ lastError = error
144
+ retryAfter = now() + retryDelay
145
+ log.error(
146
+ `mixFetch initialization failed (no retry for ${Math.round(
147
+ retryDelay / 1000
148
+ )}s):`,
149
+ error
150
+ )
151
+ // One breadcrumb per cooldown window, not one per refused caller.
152
+ // The app caps Sentry at 25 breadcrumbs, so a poll loop retrying
153
+ // every few seconds would evict the whole history in about a
154
+ // minute, blinding the crash report this failure most needs to
155
+ // appear in. Emitted before the doubling below, so it names the
156
+ // window actually armed.
157
+ log.breadcrumb('mixFetch setup failed, cooling down', {
158
+ cooldownMs: retryDelay
159
+ })
160
+ retryDelay = Math.min(retryDelay * 2, RETRY_MAX_MS)
161
+
162
+ // Best-effort: clear the library's singleton so the next setup starts
163
+ // fresh instead of reusing a broken one. Not awaited, per above.
164
+ disconnectMixFetch().catch(() => {})
165
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
166
+ delete (window ).__mixFetchGlobal
167
+
168
+ throw error
169
+ })
170
+ .finally(() => {
171
+ clearTimeout(timer)
172
+ })
173
+ }
174
+ const mixFetchModule = await mixFetchInitPromise
175
+ return mixFetchModule.mixFetch
82
176
  }
83
- const mixFetchModule = await mixFetchInitPromise
84
- return mixFetchModule.mixFetch
85
177
  }
178
+
179
+ export const initMixFetch = makeMixFetchSetup()
@@ -1,11 +1,13 @@
1
1
  /**
2
- * We only accept *.edge.app or localhost as valid domain names.
2
+ * We only accept *.edge.app, localhost, or (for http/ws only) private LAN IPv4.
3
+ * https/wss still require localhost or *.edge(test)?.app; private IPs are not
4
+ * accepted on secure schemes.
3
5
  */
4
6
  export function validateServer(server) {
5
7
  const url = new URL(server)
6
8
 
7
9
  if (url.protocol === 'http:' || url.protocol === 'ws:') {
8
- if (url.hostname === 'localhost') return
10
+ if (isPrivateHost(url.hostname)) return
9
11
  }
10
12
  if (url.protocol === 'https:' || url.protocol === 'wss:') {
11
13
  if (url.hostname === 'localhost') return
@@ -13,6 +15,34 @@ export function validateServer(server) {
13
15
  }
14
16
 
15
17
  throw new Error(
16
- `Only *.edge.app or localhost are valid login domain names, not ${url.hostname}`
18
+ `Only *.edge.app, localhost, or private LAN addresses (http/ws) are valid login domain names, not ${url.hostname}`
17
19
  )
18
20
  }
21
+
22
+ function isPrivateHost(hostname) {
23
+ if (hostname === 'localhost') return true
24
+ const octets = parseIpv4(hostname)
25
+ if (octets == null) return false
26
+ const [a, b] = octets
27
+ if (a === 127) return true
28
+ if (a === 10) return true
29
+ if (a === 192 && b === 168) return true
30
+ if (a === 172 && b >= 16 && b <= 31) return true
31
+ return false
32
+ }
33
+
34
+ function parseIpv4(hostname) {
35
+ const parts = hostname.split('.')
36
+ if (parts.length !== 4) return null
37
+ const octets = []
38
+ for (const part of parts) {
39
+ if (!/^\d{1,3}$/.test(part)) return null
40
+ const n = Number(part)
41
+ if (!Number.isInteger(n) || n < 0 || n > 255) return null
42
+ // Reject leading zeros like 010.0.0.1 which are not canonical dotted-quad
43
+ // when they reach this helper (URL parsing may already rewrite some forms).
44
+ if (part.length > 1 && part.startsWith('0')) return null
45
+ octets.push(n)
46
+ }
47
+ return octets
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edge-core-js",
3
- "version": "2.47.1",
3
+ "version": "2.48.1",
4
4
  "description": "Edge account & wallet management library",
5
5
  "keywords": [
6
6
  "bitcoin",
@@ -2172,6 +2172,13 @@ export interface EdgeContext {
2172
2172
  readonly changeLogSettings: (
2173
2173
  settings: Partial<EdgeLogSettings>
2174
2174
  ) => Promise<void>
2175
+
2176
+ /**
2177
+ * Supplies the latest device attestation token for login-server requests.
2178
+ * Pass `undefined` or `''` to clear the header. Only subsequent login-server
2179
+ * requests pick up the new value.
2180
+ */
2181
+ readonly setAttestationToken: (token: string | undefined) => Promise<void>
2175
2182
  }
2176
2183
 
2177
2184
  // ---------------------------------------------------------------------
@@ -2194,8 +2201,8 @@ export interface EdgeFakeContextOptions {
2194
2201
  logSettings?: Partial<EdgeLogSettings>
2195
2202
  plugins?: EdgeCorePluginsInit
2196
2203
 
2197
- // Allows core plugins to access the real network except for the
2198
- // login and sync servers, which remain emulated:
2204
+ // Allows core plugins to access the real network except for login, info,
2205
+ // and sync servers, which remain emulated:
2199
2206
  allowNetworkAccess?: boolean
2200
2207
 
2201
2208
  // Fake device options: