summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 03:40:43 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 03:40:43 +0200
commit9a483774e97f8612b00e3d92c4d5ebc00c21980a (patch)
tree8d08923d08f68b862565c6b52e1eb7e3102665c1 /packages/meshbay-hub
parent48124ac249b0bff42c84ab6aee9270cf7ee134d8 (diff)
downloadmeshbay-9a483774e97f8612b00e3d92c4d5ebc00c21980a.tar.gz
fix(client): refresh the token when the node says "not a member"
A member added to a group after they signed in was refused by the node, told "Not a member of this group", and had no way forward but to log out and back in. The hub bakes `groups` into the access token at login and never pushes updates, so the token said they were in nothing while the database said otherwise. This lands on every newly invited member, at their first action, and the message tells them the opposite of the truth — toto2 was a member of newdemo on the hub and read that they were not. The refusal now carries a code the client can act on (`not_a_member`) rather than prose it would have to string-match, and the SPA refreshes the access token once and retries. Refreshing re-reads membership from the database, so the retry succeeds. Once per mount: if a fresh token still says not a member, that is the truth and it gets shown. The SPA had stored a refresh token since Phase 8 and never used it. It does now. Found in a browser, doing the ordinary thing — the automated run never sees it, because e2e.py logs in after being added to the group. Tests: 233 node+common, including a handshake test that the refusal carries the code, and the full e2e run against the live deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js7
2 files changed, 43 insertions, 10 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 1ef878a..6fefe0f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -765,7 +765,7 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
return results;
}
-function GroupPage({ groupId, group, token, username, userId }) {
+function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
const [status, setStatus] = useState('idle');
const [entries, setEntries] = useState([]);
const [cached, setCached] = useState(false);
@@ -786,6 +786,9 @@ function GroupPage({ groupId, group, token, username, userId }) {
const [retryKey, setRetryKey] = useState(0);
const transportRef = useRef(null);
const gekRef = useRef(null);
+ // One refresh per mount: if a fresh token still says we are not a member, we
+ // really are not, and retrying forever would hide that.
+ const refreshedRef = useRef(false);
const submitJoinCode = useCallback((e) => {
e.preventDefault();
@@ -902,14 +905,24 @@ function GroupPage({ groupId, group, token, username, userId }) {
cacheGroupIndex(groupId, group ? group.name : groupId, freshEntries);
} catch (err) {
- if (!cancelled) {
- // The node has never seen this browser for this account: it needs a
- // one-time code from the operator before it will hand over the group
- // key. Not an error to shout about — a step in joining.
- if (err.reason === 'code_required') setNeedsCode(true);
- setError(err.message);
- setStatus('error');
+ if (cancelled) return;
+
+ // Our token predates being added to this group. Refresh once and retry
+ // rather than telling someone who was just invited that they are not a
+ // member — which is what the node honestly sees, and is useless to them.
+ if (err.reason === 'not_a_member' && !refreshedRef.current && onRefreshAuth) {
+ refreshedRef.current = true;
+ try {
+ if (await onRefreshAuth()) return; // new token → effect re-runs
+ } catch { /* fall through to the message below */ }
}
+
+ // The node has never seen this browser for this account: it needs a
+ // one-time code from the operator before it will hand over the group
+ // key. Not an error to shout about — a step in joining.
+ if (err.reason === 'code_required') setNeedsCode(true);
+ setError(err.message);
+ setStatus('error');
}
};
@@ -2653,6 +2666,20 @@ function App() {
},
};
+ // Group membership is baked into the access token at login and the hub does not
+ // push updates, so someone invited after they signed in carries a token that
+ // says they are in nothing. Refreshing re-reads membership from the database.
+ const refreshAuth = useCallback(async () => {
+ if (!user || !user.refreshToken) return null;
+ const data = await hubFetch('/v1/users/token/refresh', {
+ method: 'POST', body: { refresh_token: user.refreshToken },
+ });
+ const u = { ...user, token: data.access_token };
+ setUser(u);
+ saveAuth(u);
+ return data.access_token;
+ }, [user]);
+
let page;
if (route === '/login' || route === '/register') {
page = route === '/register'
@@ -2677,7 +2704,8 @@ function App() {
const group = groups.find(g => g.id === groupId);
page = html`<${GroupPage}
groupId=${groupId} group=${group} token=${user.token}
- username=${user.username} userId=${user.userId} />`;
+ username=${user.username} userId=${user.userId}
+ onRefreshAuth=${refreshAuth} />`;
} else if (route === '/admin') {
page = (user.role === 'moderator' || user.role === 'admin')
? html`<${AdminPage} token=${user.token} />`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 271d11f..c200674 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -314,8 +314,13 @@ class MeshBayTransport {
// A node that answers a handshake with anything other than a challenge is not
// running the mutual protocol. Accepting a bare handshake_ack here would let a
// peer skip proving GEK possession entirely (C3/C6).
- throw new Error(
+ const rejected = new Error(
'MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`));
+ // `not_a_member` usually means our token predates being added to the group;
+ // the caller refreshes it and tries again rather than showing that to someone
+ // who was invited thirty seconds ago.
+ rejected.reason = reply.code || '';
+ throw rejected;
}
/**