fix(pairing): include the request origin as a direct candidate in pairing links
Creating a device key while the UI is open through a public https domain (reverse proxy) dropped that domain from the QR payload whenever the dialog passed a preferred LAN URL, leaving only the local IP and relay as transports. Carry the non-loopback request origin as an additional direct candidate (priority 20, between LAN and relay) so paired devices can keep using the same domain on any network.
This commit is contained in:
@@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Remote access: pairing QR codes created while the app is open through a public domain (for example behind a reverse proxy) now include that domain as a connection address, so paired phones can reach the server over it instead of relying only on the local network address or the relay.
|
||||
- Usage: quota limits enabled for display now refresh every three minutes on desktop, mobile, and VS Code, with a manual refresh action available at any time.
|
||||
|
||||
## [1.18.2] - 2026-08-10
|
||||
|
||||
@@ -560,6 +560,23 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
}
|
||||
};
|
||||
|
||||
const candidateUrlType = (url) => {
|
||||
try {
|
||||
return new URL(url).protocol === 'https:' ? 'tunnel' : 'lan';
|
||||
} catch {
|
||||
return 'lan';
|
||||
}
|
||||
};
|
||||
|
||||
const isLoopbackCandidateUrl = (url) => {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// `preferredServerUrl` is the caller-supplied externally reachable URL (the
|
||||
// desktop UI reaches its own server over loopback, so the request origin is not
|
||||
// scannable — it passes the LAN URL instead). Falls back to the request origin
|
||||
@@ -573,15 +590,21 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
const pairingServerCandidates = async (req, { preferredServerUrl, includeRelay, includeDirect = true } = {}) => {
|
||||
const candidates = [];
|
||||
if (includeDirect) {
|
||||
const direct = normalizeCandidateUrl(preferredServerUrl) || requestOrigin(req);
|
||||
const preferred = normalizeCandidateUrl(preferredServerUrl);
|
||||
const origin = normalizeCandidateUrl(requestOrigin(req));
|
||||
const direct = preferred || origin;
|
||||
if (direct) {
|
||||
let type = 'lan';
|
||||
try {
|
||||
const parsed = new URL(direct);
|
||||
type = parsed.protocol === 'https:' ? 'tunnel' : 'lan';
|
||||
} catch {
|
||||
}
|
||||
candidates.push({ type, url: direct, priority: 10 });
|
||||
candidates.push({ type: candidateUrlType(direct), url: direct, priority: 10 });
|
||||
}
|
||||
// The origin the creator is browsing over (e.g. a public https domain in
|
||||
// front of a reverse proxy) is a reachable address the server cannot
|
||||
// discover from its own interfaces. Carry it as an additional direct
|
||||
// candidate so the paired device can keep using that same domain instead
|
||||
// of depending on LAN hairpin behavior or relay availability. Loopback
|
||||
// origins (desktop shell, localhost dev) are unreachable from another
|
||||
// device and are skipped.
|
||||
if (origin && direct && origin !== direct && !isLoopbackCandidateUrl(origin)) {
|
||||
candidates.push({ type: candidateUrlType(origin), url: origin, priority: 20 });
|
||||
}
|
||||
}
|
||||
// The client races candidates and falls back to relay only if the direct URL
|
||||
|
||||
@@ -331,12 +331,42 @@ describe('core-routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises the caller-supplied serverUrl as the direct candidate over the request origin', async () => {
|
||||
it('advertises the caller-supplied serverUrl first and keeps the request origin as a fallback candidate', async () => {
|
||||
const { app } = createPairingRouteApp();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/client-auth/pairing/sessions')
|
||||
.set('Host', 'runtime.example')
|
||||
.set('Host', 'chamber.example.com')
|
||||
.set('X-Forwarded-Proto', 'https')
|
||||
.send({ label: 'Pair phone', serverUrl: 'http://192.168.1.20:2606' })
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.server.candidates).toEqual([
|
||||
{ type: 'lan', url: 'http://192.168.1.20:2606', priority: 10 },
|
||||
{ type: 'tunnel', url: 'https://chamber.example.com', priority: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not duplicate the request origin when it matches the caller-supplied serverUrl', async () => {
|
||||
const { app } = createPairingRouteApp();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/client-auth/pairing/sessions')
|
||||
.set('Host', '192.168.1.20:2606')
|
||||
.send({ label: 'Pair phone', serverUrl: 'http://192.168.1.20:2606' })
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.server.candidates).toEqual([
|
||||
{ type: 'lan', url: 'http://192.168.1.20:2606', priority: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips a loopback request origin as the fallback candidate', async () => {
|
||||
const { app } = createPairingRouteApp();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/client-auth/pairing/sessions')
|
||||
.set('Host', '127.0.0.1:2606')
|
||||
.send({ label: 'Pair phone', serverUrl: 'http://192.168.1.20:2606' })
|
||||
.expect(201);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user