aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-14 23:02:49 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-14 23:02:49 +0200
commit3131630d900aa4a72bf880bfa9c5232bff64d6d6 (patch)
tree5ef229f9a3f589fad6e7543f14b58ec7f9526758 /packages/meshbay-hub/src
parent422647e7898b8df67cd11f8fb060aec23dec84aa (diff)
downloadmeshbay-3131630d900aa4a72bf880bfa9c5232bff64d6d6.tar.gz
fix(client): link a device from the application; approve one match only
"Get a linking code" called the connection the page had just closed on the node's unknown_device refusal, so it never filed anything. It now retries with the request filed on that connection, before the proof (MNP 6.3), and the page tries the group again after each refusal until a linked device approves the code. Offered only in the desktop application: a browser's minted identity is gone by its next connection. Approving refuses a code that matches more than one pending request: filing needs only the account's token, so somebody holding the passphrase who saw a code could file their own keys under it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js68
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js15
3 files changed, 86 insertions, 12 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 3e3b483..422d90b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -26,6 +26,17 @@ const GroupSettingsPanel = lazy(() => import('./group-settings.js'), 'GroupSetti
import { reportIndexPush } from './index-dock.js';
import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js';
+// While a linking code is shown, how long after a refused try the group is
+// tried again: a person typing the code on another device takes longer, and
+// each try is a connection.
+const DEVICE_RETRY_MS = 5000;
+
+/** The node's `expires_at` (ISO) in epoch seconds; 0 when it gave none. */
+function _untilSeconds(iso) {
+ const ms = Date.parse(iso || '');
+ return Number.isFinite(ms) ? ms / 1000 : 0;
+}
+
/**
* The group shell: everything a group's "applications" (Chat, Files, and
* whatever registers in apps.js next) share — the WebRTC connection, the file
@@ -218,6 +229,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
}, []);
const browserAccessOff = !nativeKeys && userPrefs && userPrefs.browser_access === 'off';
const [deviceCode, setDeviceCode] = useState('');
+ // While a code is shown: until when the node keeps its request (epoch
+ // seconds). The group is tried again after each refusal until then.
+ const deviceWaitRef = useRef(0);
const [codeInput, setCodeInput] = useState('');
// This browser has never derived the passphrase-bundle key (fresh browser,
// cleared storage, or a device-key sign-in). Ask for the passphrase here
@@ -236,9 +250,15 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// set to `true` while looking at one group would otherwise silently disable
// the retry for every group opened afterward in the same session, forever.
const refreshedRef = useRef(false);
+ // Set by "Get a linking code" for the one connection that files the request.
+ const fileDeviceRef = useRef(false);
useEffect(() => {
refreshedRef.current = false;
+ fileDeviceRef.current = false;
setNeedsPass(false);
+ deviceWaitRef.current = 0;
+ setNeedsDevice(false);
+ setDeviceCode('');
}, [groupId]);
const submitJoinCode = useCallback((e) => {
@@ -367,6 +387,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// Dropped by the teardown below, so a transport handed on to a running
// download (`releaseWhenIdle`) stops driving a page that is gone.
let offReconnect = null;
+ let deviceRetry = null;
// The cache is written here and read only by the search page. It used to
// seed this list too, which put a stale index on screen and then raced the
@@ -453,6 +474,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
transport.onDeviceIdentity = (ok) => {
if (!cancelled) setDeviceReady(ok);
};
+ // "Get a linking code" retries with this set: the request is filed
+ // on the connection the node refused, which is closed right after.
+ transport.fileDeviceRequest = fileDeviceRef.current;
try {
ack = await transport.connect(
n.node_id, live, groupId, null, identity, session.bundleKey,
@@ -615,8 +639,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
})();
});
- // We are in: an invitation to this group has served its purpose.
+ // We are in: an invitation to this group has served its purpose, and a
+ // linking code shown before has been approved.
if (onJoined) onJoined(groupId);
+ deviceWaitRef.current = 0;
+ setNeedsDevice(false);
+ setDeviceCode('');
const indexMsg = await transport.fetchIndex();
if (cancelled) return;
@@ -680,7 +708,30 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// whole point of device linking: a second browser or a native client
// must not cost anyone a support request.
if (err.reason === 'unknown_device') setNeedsDevice(true);
- setError(err.message);
+ fileDeviceRef.current = false;
+ if (err.deviceCode) {
+ setDeviceCode(err.deviceCode);
+ deviceWaitRef.current = _untilSeconds(err.deviceExpiresAt)
+ || Date.now() / 1000 + 3600;
+ }
+ // A code is shown: the group opens by itself once a linked device
+ // approves it, as the command-line client goes on by itself. Tried
+ // again only once this try is over, so a slow connection is never cut
+ // short by the next; past the request's life the code is dropped and
+ // the button comes back for a new one.
+ if (deviceWaitRef.current && err.reason === 'unknown_device') {
+ if (Date.now() / 1000 < deviceWaitRef.current) {
+ deviceRetry = setTimeout(() => setRetryKey(k => k + 1), DEVICE_RETRY_MS);
+ } else {
+ deviceWaitRef.current = 0;
+ setDeviceCode('');
+ }
+ }
+ // In the application the linking form says all there is to say, and
+ // while a code is shown this is the refusal of each retry, not news.
+ const quiet = err.reason === 'unknown_device' && await platform.nativeKeys();
+ if (cancelled) return;
+ if (!quiet) setError(err.message);
setStatus('error');
if (transportRef.current) {
try { transportRef.current.close(); } catch { /* already gone */ }
@@ -704,6 +755,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
return () => {
cancelled = true;
if (offReconnect) { offReconnect(); offReconnect = null; }
+ clearTimeout(deviceRetry);
// Nothing will update this group's dock row once the page lets go of it.
reportIndexPush(groupId, null);
if (transportRef.current) {
@@ -967,17 +1019,15 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
<button class="admin-btn" style="margin-left:8px;font-size:0.9em"
onClick=${() => setRetryKey(k => k + 1)}>${t('group.retry')}</button>
</div>`}
- ${needsDevice && html`
+ ${needsDevice && nativeKeys && html`
<div class="invite-form" style="margin-bottom:12px">
<h4>${t('device.add_title')}</h4>
<p class="settings-hint">${t('device.add_hint')}</p>
${!deviceCode && html`
- <button class="admin-btn" onClick=${async () => {
- try {
- const transport = transportRef.current;
- const out = await transport.requestDeviceAdd(userId);
- setDeviceCode(out.code);
- } catch (err) { setError(err.message); }
+ <button class="admin-btn" onClick=${() => {
+ // The refused connection is closed: a new one files the request.
+ fileDeviceRef.current = true;
+ setRetryKey(k => k + 1);
}}>${t('device.add_btn')}</button>
`}
${deviceCode && html`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
index 3d56f69..5c6adef 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
@@ -227,15 +227,24 @@ extendTransport(class {
const listed = await this._sendAndWait({ type: 'device_lookup', v: '0.1' });
if (listed.type === 'error') throw new Error(listed.detail || 'Not found');
- let match = null;
+ // Every match, not the first: a request is filed with the account's token
+ // alone, so somebody holding the passphrase who saw the code on screen can
+ // file a second one with the same code and keys of their own. Neither is
+ // approved then — the person's device asks again, with a new code.
+ const matches = [];
for (const req of listed.requests || []) {
const expect = await C.deviceCodeHash(
normalized, req.pk_ed25519, req.pk_x25519);
- if (expect === req.code_hash) { match = req; break; }
+ if (expect === req.code_hash) matches.push(req);
}
- if (!match) {
+ if (!matches.length) {
throw new Error('No device is waiting with that code');
}
+ if (matches.length > 1) {
+ throw new Error('More than one device is waiting with that code, so none was '
+ + 'approved. Get a new code on the device you are linking.');
+ }
+ const match = matches[0];
return this._countersign(userId, match.code_hash,
match.pk_ed25519, match.pk_x25519);
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index c86d668..a07f431 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -1178,6 +1178,21 @@ class MeshBayTransport {
} catch (e) {
// The UI turns this into "ask the operator for an invite code".
this._joinError = e;
+ // Asked for by the page ("Get a linking code"): filed on this same
+ // connection, still before the proof (MNP 6.3), because the page
+ // closes a connection that did not finish. Only for an identity the
+ // application keeps: one a browser minted is gone by its next
+ // connection, and approving it would link nothing.
+ if (e.reason === 'unknown_device' && this.fileDeviceRequest
+ && this._identity.native) {
+ try {
+ const filed = await this.requestDeviceAdd(userId);
+ e.deviceCode = filed.code;
+ e.deviceExpiresAt = filed.expiresAt;
+ } catch (fe) {
+ e.message = fe.message;
+ }
+ }
}
}