From 1e8eae1f3de765fcd8163716de6e5fe5aab5a034 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 09:33:28 +0200 Subject: feat(hub): one account, several linked nodes An account links up to MAX_NODES_PER_ACCOUNT node keys (node_keys table) instead of the one users.pk_node_ed25519; the migration carries each account's key over and the downgrade keeps the last one linked. /v1/nodes/auth accepts any linked key, and its token names the key that won it (nk). PUT /me/node_key adds a key, idempotently, 409 past the limit; DELETE /me/node_key names the key: a browser session any of its own, a node only its own. Unlinking a key closes that node's socket and refuses its tokens at once, on REST and on the socket; the account's other nodes are untouched. /pubkeys no longer gives a node key to other accounts, and gives the caller its last linked one, which installed desktop apps read. The node reports waiting_for_node_key while its key is unlinked and signs in again as soon as the hub says so, so a key linked again is back within seconds; unlinking itself signs in first to hold a token naming its key. Profile -> Linked nodes lists each node (connected, last seen, IP, linked since, This machine in the desktop app) with Copy and Unlink, and adds a key by hand. The Node page banner, Create group and the desktop app add this machine's key instead of replacing another's. Messages that pointed at Settings -> Link Node point at Profile -> Linked nodes. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/static/profile-page.js | 159 +++++++++++++++------ 1 file changed, 116 insertions(+), 43 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/profile-page.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js index b52a8c6..406ffc8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js @@ -6,7 +6,7 @@ import { ask } from './ask.js'; import { Icon } from './icon.js'; import * as platform from './platform.js'; import { - hubFetch, HUB, session, setAuth, mirrorBrowserAccess, + hubFetch, HUB, session, setAuth, mirrorBrowserAccess, linkedNodeKeys, _storeBundleKey, _loadBundleKey, _storeRecoveryKey, } from './hub-client.js'; @@ -25,12 +25,36 @@ async function lockedMessage(token) { } } +// An Ed25519 key is 44 characters with nothing to tell two apart at a glance; +// both ends are what a person compares against `meshbay-node status`. +export function shortNodeKey(pk) { + const k = String(pk || ''); + return k.length > 16 ? `${k.slice(0, 8)}…${k.slice(-6)}` : k; +} + +function nodeKeyMeta(k) { + const parts = [k.connected ? t('settings.node_keys_connected') + : k.last_seen ? t('settings.node_keys_last_seen', + { when: new Date(k.last_seen).toLocaleString() }) + : t('settings.node_keys_never')]; + if (k.observed_ip) parts.push(k.observed_ip); + if (k.linked_at) { + parts.push(t('settings.node_keys_linked_on', + { when: new Date(k.linked_at).toLocaleDateString() })); + } + return parts; +} + export function ProfilePage({ user, onLogout }) { const [nodeKey, setNodeKey] = useState(''); const [hubInput, setHubInput] = useState(''); const [hubError, setHubError] = useState(''); - const [currentNodeKey, setCurrentNodeKey] = useState(null); - const [nodeKeyStatus, setNodeKeyStatus] = useState(''); + // null until the hub answers, so "no node linked" is never said before it has. + const [nodeKeys, setNodeKeys] = useState(null); + const [nodeKeyLimit, setNodeKeyLimit] = useState(0); + const [thisMachineKey, setThisMachineKey] = useState(''); + const [copiedKey, setCopiedKey] = useState(''); + const [nodeKeyStatus, setNodeKeyStatus] = useState(null); // { text, ok } const [nodeKeyLoading, setNodeKeyLoading] = useState(false); const [email, setEmail] = useState(''); const [emailDraft, setEmailDraft] = useState(''); @@ -289,13 +313,29 @@ export function ProfilePage({ user, onLogout }) { if (data.email) { setEmail(data.email); setEmailDraft(data.email); } }) .catch(() => {}); - hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token }) - .then(data => { - if (data.pk_node_ed25519) setCurrentNodeKey(data.pk_node_ed25519); - }) - .catch(() => {}); }, [user.username, user.token]); + const loadNodeKeys = useCallback(async () => { + try { + const data = await linkedNodeKeys(user.token); + setNodeKeys(data.keys || []); + setNodeKeyLimit(data.limit || 0); + } catch (e) { + setNodeKeyStatus({ text: e.message, ok: false }); + } + }, [user.token]); + + useEffect(() => { loadNodeKeys(); }, [loadNodeKeys]); + + // In the desktop app, which of the linked keys is this machine's own node: + // the keys alone do not say which machine is which. + useEffect(() => { + if (!platform.node.service || !platform.node.service.available) return; + platform.node.service.status() + .then(r => setThisMachineKey((r && r.pk_node_ed25519) || '')) + .catch(() => {}); + }, []); + const saveEmail = useCallback(async () => { const val = emailDraft.trim(); if (!val || val === email) { setEmailEditing(false); return; } @@ -354,41 +394,52 @@ export function ProfilePage({ user, onLogout }) { const key = nodeKey.trim(); if (!key) return; setNodeKeyLoading(true); - setNodeKeyStatus(''); + setNodeKeyStatus(null); try { await hubFetch('/v1/users/me/node_key', { method: 'PUT', token: user.token, body: { pk_node_ed25519: key }, }); - setCurrentNodeKey(key); setNodeKey(''); - setNodeKeyStatus(t('settings.node_key_success')); + setNodeKeyStatus({ text: t('settings.node_key_success'), ok: true }); + await loadNodeKeys(); } catch (e) { - setNodeKeyStatus(e.message); + setNodeKeyStatus({ text: e.message, ok: false }); } finally { setNodeKeyLoading(false); } - }, [nodeKey, user.token]); - - // Unlinking was only on the linked machine's own Node page, so an account - // whose node was on a machine since wiped or lost had no way out here; and a - // new machine's node will not take over a key that is still linked - // (main.js ensureNode). The hub accepts it from any session of the account. - const unlinkNodeKey = useCallback(async () => { - if (!await ask(t('settings.node_key_unlink_confirm'))) return; + }, [nodeKey, user.token, loadNodeKeys]); + + // Any session of the account can let go of any of its nodes, so a machine + // since wiped or lost is not stuck on the account. Only the one named stops + // serving; the hub closes its connection at once. + const unlinkNodeKey = useCallback(async (pk) => { + if (!await ask(t('settings.node_key_unlink_confirm', { key: shortNodeKey(pk) }))) return; setNodeKeyLoading(true); - setNodeKeyStatus(''); + setNodeKeyStatus(null); try { - await hubFetch('/v1/users/me/node_key', { method: 'DELETE', token: user.token }); - setCurrentNodeKey(null); - setNodeKeyStatus(t('settings.node_key_unlinked')); + await hubFetch('/v1/users/me/node_key', { + method: 'DELETE', token: user.token, body: { pk_node_ed25519: pk }, + }); + setNodeKeys(keys => (keys || []).filter(k => k.pk_node_ed25519 !== pk)); + setNodeKeyStatus({ text: t('settings.node_key_unlinked'), ok: true }); } catch (e) { - setNodeKeyStatus(e.message); + setNodeKeyStatus({ text: e.message, ok: false }); } finally { setNodeKeyLoading(false); } }, [user.token]); + const copyNodeKey = useCallback(async (pk) => { + try { + await navigator.clipboard.writeText(pk); + setCopiedKey(pk); + setTimeout(() => setCopiedKey(c => (c === pk ? '' : c)), 2000); + } catch { /* clipboard blocked -- the full key is in the tooltip */ } + }, []); + + const atLimit = nodeKeys !== null && nodeKeyLimit > 0 && nodeKeys.length >= nodeKeyLimit; + return html`

${t('profile.title')}

@@ -449,31 +500,53 @@ export function ProfilePage({ user, onLogout }) {

${t('settings.node_key')}

-

${t('settings.node_key_desc')}

- ${currentNodeKey && html` -
- ${t('settings.node_key_current')} - ${currentNodeKey} - -
- `} -
- ${t('settings.node_key_desc')}

+ ${nodeKeys !== null && nodeKeys.length === 0 && html` +

${t('settings.node_keys_none')}

`} + ${nodeKeys !== null && nodeKeys.length > 0 && html` +
    + ${nodeKeys.map(k => html` +
  • + +
    +
    + + ${shortNodeKey(k.pk_node_ed25519)} + ${k.pk_node_ed25519 === thisMachineKey && html` + ${t('settings.node_keys_this_machine')}`} +
    +
    ${nodeKeyMeta(k).join(' · ')}
    +
    +
    + + +
    +
  • `)} +
`} +
+ setNodeKey(e.target.value)} - onKeyDown=${e => e.key === 'Enter' && submitNodeKey()} /> + onKeyDown=${e => e.key === 'Enter' && !atLimit && submitNodeKey()} + disabled=${atLimit} aria-label=${t('settings.node_key_placeholder')} />
+ ${nodeKeys !== null && nodeKeyLimit > 0 && html` +

+ ${atLimit ? t('settings.node_keys_at_limit', { limit: nodeKeyLimit }) + : t('settings.node_keys_count', { n: nodeKeys.length, limit: nodeKeyLimit })} +

`} ${nodeKeyStatus && html` -

- ${nodeKeyStatus} -

- `} +

+ ${nodeKeyStatus.text} +

`}
-- cgit v1.2.3