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.
- package/CHANGELOG.md +10 -0
- package/android/src/main/assets/edge-core-js/edge-core.js +1 -1
- package/lib/core/actions.js +5 -0
- package/lib/core/context/context-api.js +4 -0
- package/lib/core/fake/fake-world.js +17 -6
- package/lib/core/login/login-fetch.js +5 -2
- package/lib/core/login/login-reducer.js +8 -0
- package/lib/node/index.js +79 -22
- package/lib/types/types.js +7 -0
- package/lib/util/nym.js +137 -43
- package/lib/util/validateServer.js +33 -3
- package/package.json +1 -1
- package/src/types/types.ts +9 -2
package/lib/core/actions.js
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
119
|
-
} catch (error) {
|
|
120
|
-
return io.fetch(uri, opts)
|
|
130
|
+
if (isFakeAccountInfrastructure(uri)) {
|
|
131
|
+
return fakeFetch(uri, opts)
|
|
121
132
|
}
|
|
122
|
-
return
|
|
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
|
-
|
|
14456
|
-
|
|
14457
|
-
} catch (error) {
|
|
14458
|
-
return io.fetch(uri, opts);
|
|
14514
|
+
if (isFakeAccountInfrastructure(uri)) {
|
|
14515
|
+
return fakeFetch(uri, opts);
|
|
14459
14516
|
}
|
|
14460
|
-
return
|
|
14517
|
+
return io.fetch(uri, opts);
|
|
14461
14518
|
};
|
|
14462
14519
|
const fakeIo = {
|
|
14463
14520
|
...io,
|
package/lib/types/types.js
CHANGED
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
|
-
|
|
36
|
-
|
|
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
|
-
*
|
|
40
|
-
*
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
80
|
-
|
|
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
|
|
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
|
|
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
|
|
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
package/src/types/types.ts
CHANGED
|
@@ -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
|
|
2198
|
-
//
|
|
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:
|