aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js188
-rw-r--r--packages/meshbay-hub/tests/harness/session_harness.mjs165
-rw-r--r--packages/meshbay-hub/tests/test_session_renewal.py227
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py8
5 files changed, 582 insertions, 19 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py
index 31912d1..b508e45 100644
--- a/packages/meshbay-hub/src/meshbay_hub/config.py
+++ b/packages/meshbay-hub/src/meshbay_hub/config.py
@@ -46,7 +46,18 @@ class HubIdentityConfig:
@dataclass
class JWTConfig:
- access_token_ttl: int = 3600 # 1 hour
+ # How long an access token stays good. It is not the session — the refresh
+ # token below is, and the SPA renews against it well before this runs out,
+ # so a film or a working day never meets this number.
+ #
+ # What it does bound is a token that leaks: revoking a member or suspending
+ # an account both take effect at once (the hub reloads the account on every
+ # request, and pushes signed revocations to nodes), but there is no way to
+ # kill one issued token short of that. Four hours keeps the window short
+ # while renewing several times a day — which also means the renewal path is
+ # exercised constantly rather than twice, and cannot rot unnoticed the way
+ # it did when nothing used it at all.
+ access_token_ttl: int = 14400 # 4 hours
refresh_token_ttl: int = 86400 * 30 # 30 days
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 198bc78..cb7a462 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -11,6 +11,14 @@ import * as downloads from './downloads.js';
const HUB = '';
const AUTH_KEY = 'mb_auth';
+// Renew an access token with this much life left rather than waiting for it to
+// fail. Generous against a one-hour token: a film is watched without the hub
+// hearing a word, and coming back to a tab that has been asleep for an hour
+// should not cost a round trip before the first click works.
+const TOKEN_RENEW_MARGIN_S = 600;
+// How often to look. Cheap — it reads a timestamp out of the token and almost
+// always does nothing.
+const TOKEN_CHECK_MS = 60000;
const THEME_KEY = 'mb_theme';
const IDB_NAME = 'meshbay';
const IDB_VERSION = 1;
@@ -171,6 +179,94 @@ function saveAuth(auth) {
}
}
+// ── Session ──────────────────────────────────────────────────────────────────
+//
+// The access token lasts an hour and the refresh token thirty days. Nothing was
+// using the second: `hubFetch` reported a 401 as an error like any other, so an
+// hour of watching a film — during which the hub hears nothing, because the
+// video comes over WebRTC — ended with "token expired or invalid" and no way
+// out but signing out and back in. Reopening the tab the next day did the same,
+// with a perfectly good refresh token sitting in localStorage beside the stale
+// access one.
+//
+// This lives outside the component because `hubFetch` is a plain function and
+// has to be able to renew a token mid-request without every caller passing the
+// machinery down to it.
+
+let _auth = loadAuth();
+let _onAuthChange = null; // set by App, so the UI follows a background renewal
+let _refreshing = null; // in flight, shared: see refreshAccessToken
+
+function setAuth(auth) {
+ _auth = auth;
+ saveAuth(auth);
+ if (_onAuthChange) _onAuthChange(auth);
+}
+
+/** Seconds until this JWT expires, or null if it says nothing useful. */
+function tokenLifeLeft(token) {
+ try {
+ const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
+ if (!payload.exp) return null;
+ return payload.exp - Math.floor(Date.now() / 1000);
+ } catch {
+ return null; // not a JWT we can read; treat as unknown, never as expired
+ }
+}
+
+/**
+ * Trade the refresh token for a new pair.
+ *
+ * The hub rotates: it revokes the token presented and returns a new one, and a
+ * revoked token presented again revokes the whole family. So the new one must
+ * be stored — the previous code kept only the access token and dropped its
+ * replacement, which burned the refresh token on first use and locked the
+ * account out of renewal on the second. That is why signing out and in was the
+ * only way back.
+ *
+ * Concurrent callers share one request. Two 401s racing would otherwise send
+ * the same refresh token twice, and the second would look exactly like theft.
+ */
+async function refreshAccessToken() {
+ if (!_auth || !_auth.refreshToken) return null;
+ if (_refreshing) return _refreshing;
+ _refreshing = (async () => {
+ try {
+ const r = await fetch(HUB + '/v1/users/token/refresh', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refresh_token: _auth.refreshToken }),
+ });
+ if (!r.ok) {
+ // Expired, revoked, or the family was torn down. Nothing to salvage:
+ // sign out cleanly rather than leave a session that fails every call.
+ setAuth(null);
+ return null;
+ }
+ const data = await r.json();
+ setAuth({
+ ..._auth,
+ token: data.access_token,
+ refreshToken: data.refresh_token || _auth.refreshToken,
+ });
+ return data.access_token;
+ } catch {
+ return null; // offline: keep the session, the next call can try again
+ } finally {
+ _refreshing = null;
+ }
+ })();
+ return _refreshing;
+}
+
+/** Renew before it bites, rather than after. */
+async function ensureFreshToken() {
+ if (!_auth || !_auth.token) return null;
+ const left = tokenLifeLeft(_auth.token);
+ if (left !== null && left > TOKEN_RENEW_MARGIN_S) return _auth.token;
+ return refreshAccessToken();
+}
+
// ── Theme ────────────────────────────────────────────────────────────────────
function getInitialTheme() {
@@ -188,13 +284,26 @@ function resolveTheme(pref) {
// ── Hub API ──────────────────────────────────────────────────────────────────
-async function hubFetch(path, { method = 'GET', body, token } = {}) {
+async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) {
const headers = {};
if (body) headers['Content-Type'] = 'application/json';
- if (token) headers['Authorization'] = `Bearer ${token}`;
+ // Prefer the token the session currently holds. Callers read theirs from
+ // React state, which is a render behind a renewal that happened in the
+ // background — and sending the stale one would 401 for no reason.
+ const bearer = token && _auth && _auth.token ? _auth.token : token;
+ if (bearer) headers['Authorization'] = `Bearer ${bearer}`;
const opts = { method, headers };
if (body) opts.body = JSON.stringify(body);
const r = await fetch(HUB + path, opts);
+ if (r.status === 401 && bearer && !_retried) {
+ // The one case worth a second attempt: the access token aged out while
+ // nothing was talking to the hub. Renew once and replay. If the renewal
+ // fails it signs out, and the replay below is skipped.
+ const fresh = await refreshAccessToken();
+ if (fresh) {
+ return hubFetch(path, { method, body, token: fresh, _retried: true });
+ }
+ }
if (!r.ok) {
const err = await r.json().catch(() => ({ detail: r.statusText }));
const detail = Array.isArray(err.detail)
@@ -1198,11 +1307,18 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
setStatus('connecting');
const nodeId = nodesData.nodes[0].node_id;
- const transport = new window.MeshBayTransport('', token);
+ // Renewed here rather than taken from the prop. This effect no longer
+ // re-runs when the token rotates (see the dependency list below), so
+ // the captured one can be older than the session's — and it is used to
+ // sign the offer to the hub, where an expired one is a 401 and no
+ // connection at all. Renewals are shared, so if one is already in
+ // flight this waits for it instead of starting a second.
+ const live = (await ensureFreshToken()) || token;
+ const transport = new window.MeshBayTransport('', live);
transportRef.current = transport;
const ack = await transport.connect(
- nodeId, token, groupId, null, sessionKeys, _bundleKey, username,
+ nodeId, live, groupId, null, sessionKeys, _bundleKey, username,
userId, _pendingJoinCode);
_pendingJoinCode = null;
if (cancelled) return;
@@ -1291,7 +1407,18 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
// `group` object, which the hub poll re-creates, and re-running this effect
// means tearing down the WebRTC connection. groupId is here, so a real group
// change still re-captures it.
- }, [groupId, token, retryKey]);
+ //
+ // Neither is the token itself, only whether there is one. It used to be a
+ // dependency and that was harmless while a token never changed during a
+ // session — it only expired. Now that the session renews itself, the string
+ // rotates, and this effect tore the WebRTC connection down and rebuilt it
+ // every time. Worst on arrival: a stored token past its life is renewed the
+ // instant the page mounts, which is exactly when the group page is
+ // negotiating ICE, so the connection was abandoned mid-handshake and the
+ // node sat in `connecting` for ever. The live token is read inside
+ // `connect()` instead. Signing out unmounts this page; signing in mounts
+ // it; nothing in between should disturb a working connection.
+ }, [groupId, Boolean(token), retryKey]);
const downloadFile = useCallback(async (entry) => {
const transport = transportRef.current;
@@ -4214,6 +4341,33 @@ function App() {
const resolved = resolveTheme(theme);
+ // Keep the session alive without anyone having to think about it.
+ useEffect(() => {
+ // A renewal can happen inside hubFetch, well away from any render. This is
+ // how the component learns about it — including a failed one, which sets
+ // null and lands on the login page instead of failing every later call.
+ _onAuthChange = (auth) => setUser(auth);
+
+ // On mount above all: a tab reopened tomorrow holds an hour-old access
+ // token and a refresh token good for a month, and used to greet its owner
+ // with "invalid token" rather than spending the second on renewing it.
+ ensureFreshToken();
+
+ const timer = setInterval(ensureFreshToken, TOKEN_CHECK_MS);
+ // A backgrounded tab has its timers throttled hard, so the check above may
+ // not have run for the whole time it was away. Coming back is exactly when
+ // the token is most likely to be stale.
+ const onVisible = () => {
+ if (document.visibilityState === 'visible') ensureFreshToken();
+ };
+ document.addEventListener('visibilitychange', onVisible);
+ return () => {
+ _onAuthChange = null;
+ clearInterval(timer);
+ document.removeEventListener('visibilitychange', onVisible);
+ };
+ }, []);
+
useEffect(() => {
document.documentElement.className = `theme-${resolved}`;
localStorage.setItem(THEME_KEY, theme);
@@ -4320,15 +4474,18 @@ function App() {
}
const me = await hubFetch('/v1/users/me', { token });
const u = { username, userId: me.user_id, token, refreshToken, role: me.role };
+ // setAuth, not saveAuth: it is the one writer that also updates the copy
+ // hubFetch renews from. Storing the session without it left the renewal
+ // path with no refresh token to present.
+ setAuth(u);
setUser(u);
- saveAuth(u);
},
logout: () => {
// Navigating away leaves transfers running; signing out does not. They
// are moving data on tokens that are about to stop being ours.
transfers.reset();
+ setAuth(null);
setUser(null);
- saveAuth(null);
setGroups([]);
navigate('/login');
},
@@ -4337,16 +4494,13 @@ 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]);
+ // Goes through refreshAccessToken like everything else. It used to call the
+ // endpoint here and keep only the access token, dropping the rotated refresh
+ // token that came back with it — so the refresh token was spent on first use,
+ // and presenting the spent one again revoked the whole family. Which is how
+ // a session that should last a month ended at "invalid token" with signing
+ // out as the only way back.
+ const refreshAuth = useCallback(() => refreshAccessToken(), []);
let page;
if (route === '/login' || route === '/register') {
diff --git a/packages/meshbay-hub/tests/harness/session_harness.mjs b/packages/meshbay-hub/tests/harness/session_harness.mjs
new file mode 100644
index 0000000..f0d0cb2
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/session_harness.mjs
@@ -0,0 +1,165 @@
+// Run the shipped session code against a hub that behaves like the real one.
+//
+// The defect this guards was not subtle once seen: the hub rotates refresh
+// tokens — it revokes the one presented and returns a replacement, and a
+// revoked token presented again revokes the entire family — and the client kept
+// only the access token out of that response. So the refresh token was spent on
+// first use and the second attempt locked the account out of renewal
+// altogether, leaving signing out and back in as the only way to carry on.
+//
+// The fake hub below enforces exactly that rule, which is the point: a stub
+// that happily accepts the same refresh token twice would have passed against
+// the broken client.
+import { readFileSync } from 'fs';
+
+const app = readFileSync(process.argv[2], 'utf8');
+const cfg = JSON.parse(process.argv[3] || '{}');
+const { scenario = 'expired-access' } = cfg;
+
+// ── The pieces of app.js under test, lifted as text ──────────────────────────
+const between = (from, to) => {
+ const i = app.indexOf(from);
+ if (i < 0) throw new Error(`not found: ${from}`);
+ const j = app.indexOf(to, i);
+ if (j < 0) throw new Error(`not found: ${to}`);
+ return app.slice(i, j);
+};
+const sessionBlock = between('let _auth = loadAuth();', '\n// ── Theme');
+const hubFetchFn = between('async function hubFetch(', '\n// ── Router');
+
+// ── The world ────────────────────────────────────────────────────────────────
+const store = new Map();
+const localStorage = {
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
+ setItem: (k, v) => store.set(k, v),
+ removeItem: (k) => store.delete(k),
+};
+const AUTH_KEY = 'mb_auth';
+const HUB = '';
+const TOKEN_RENEW_MARGIN_S = Number(app.match(/const TOKEN_RENEW_MARGIN_S = (\d+)/)[1]);
+
+const jwt = (secondsLeft) => {
+ const payload = Buffer.from(JSON.stringify({
+ exp: Math.floor(Date.now() / 1000) + secondsLeft,
+ })).toString('base64url');
+ return `head.${payload}.sig`;
+};
+
+// The hub. One live refresh token at a time; presenting a revoked one kills the
+// family, as the real endpoint does.
+let live = 'RT-1';
+let issued = 1;
+let familyRevoked = false;
+const seen = [];
+let refreshCalls = 0;
+
+const fakeFetch = async (url, opts = {}) => {
+ if (url.endsWith('/v1/users/token/refresh')) {
+ refreshCalls++;
+ const presented = JSON.parse(opts.body).refresh_token;
+ seen.push(presented);
+ if (familyRevoked || presented !== live) {
+ familyRevoked = true;
+ return { ok: false, status: 401,
+ json: async () => ({ detail: 'Token reuse detected — family revoked' }) };
+ }
+ live = `RT-${++issued}`;
+ return {
+ ok: true, status: 200,
+ json: async () => ({ access_token: jwt(3600), refresh_token: live,
+ token_type: 'bearer', expires_in: 3600 }),
+ };
+ }
+ // Any other endpoint: 401 unless the bearer is a token that has life left.
+ const auth = (opts.headers || {})['Authorization'] || '';
+ const token = auth.replace('Bearer ', '');
+ let alive = false;
+ try {
+ alive = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()).exp
+ > Math.floor(Date.now() / 1000);
+ } catch { alive = false; }
+ calls.push({ url, token, alive });
+ if (!alive) {
+ return { ok: false, status: 401, json: async () => ({ detail: 'Token expired or invalid' }) };
+ }
+ return { ok: true, status: 200, json: async () => ({ ok: true }) };
+};
+const calls = [];
+
+let _bundleKey = null;
+const _clearKeyDB = () => {};
+function loadAuth() {
+ try { return JSON.parse(localStorage.getItem(AUTH_KEY)); } catch { return null; }
+}
+function saveAuth(auth) {
+ if (auth) localStorage.setItem(AUTH_KEY, JSON.stringify(auth));
+ else { localStorage.removeItem(AUTH_KEY); _bundleKey = null; _clearKeyDB(); }
+}
+
+// The session starts with an access token that expired an hour ago and a
+// refresh token that is still perfectly good — a tab reopened the next morning.
+const startingLife = scenario === 'fresh-access' ? 3600
+ : scenario === 'nearly-expired' ? 60
+ : -60;
+localStorage.setItem(AUTH_KEY, JSON.stringify({
+ username: 'someone', userId: 'u1', role: 'user',
+ token: jwt(startingLife), refreshToken: 'RT-1',
+}));
+
+const ctx = {
+ localStorage, AUTH_KEY, HUB, TOKEN_RENEW_MARGIN_S,
+ loadAuth, saveAuth, fetch: fakeFetch, atob: (s) => Buffer.from(s, 'base64').toString('binary'),
+ console,
+};
+const fns = new Function(...Object.keys(ctx),
+ sessionBlock + '\n' + hubFetchFn +
+ '\n return {hubFetch, refreshAccessToken, ensureFreshToken, tokenLifeLeft,' +
+ ' getAuth: () => _auth};')(...Object.values(ctx));
+
+// ── The run ──────────────────────────────────────────────────────────────────
+const out = { scenario };
+const stored = () => JSON.parse(localStorage.getItem(AUTH_KEY) || 'null');
+
+if (scenario === 'concurrent') {
+ // Two requests hit a dead token at the same instant. If each renews on its
+ // own, the second presents a token the first has already spent, and the hub
+ // tears down the family.
+ const before = stored().token;
+ // Either may reject once the family is gone; the run still has to report.
+ await Promise.allSettled([
+ fns.hubFetch('/v1/groups/mine', { token: before }),
+ fns.hubFetch('/v1/users/me', { token: before }),
+ ]);
+} else if (scenario === 'repeat') {
+ // Renew several times over. Each response carries a new refresh token, and
+ // keeping the old one would be caught on the very next attempt.
+ for (let i = 0; i < 4; i++) {
+ await fns.refreshAccessToken();
+ // Age the access token again so the next one really has to renew. A
+ // rejected renewal signs the session out, and there is then nothing left
+ // to age — which is the outcome under test, not a reason to stop.
+ const a = stored();
+ if (!a) break;
+ localStorage.setItem(AUTH_KEY, JSON.stringify({ ...a, token: jwt(-60) }));
+ const live_ = fns.getAuth();
+ if (live_) live_.token = jwt(-60);
+ }
+} else if (scenario === 'refresh-rejected') {
+ localStorage.setItem(AUTH_KEY, JSON.stringify({ ...stored(), refreshToken: 'RT-STALE' }));
+ fns.getAuth().refreshToken = 'RT-STALE';
+ try { await fns.hubFetch('/v1/groups/mine', { token: stored().token }); }
+ catch (e) { out.threw = String(e.message); }
+} else {
+ await fns.ensureFreshToken();
+ await fns.hubFetch('/v1/groups/mine', { token: stored() ? stored().token : null });
+}
+
+out.refreshCalls = refreshCalls;
+out.refreshTokensPresented = seen;
+out.familyRevoked = familyRevoked;
+out.signedOut = stored() === null;
+out.storedRefreshToken = stored() ? stored().refreshToken : null;
+out.apiCalls = calls.map(c => ({ url: c.url, accepted: c.alive }));
+out.everRejected = calls.some(c => !c.alive);
+out.finalCallAccepted = calls.length ? calls[calls.length - 1].alive : null;
+console.log(JSON.stringify(out));
diff --git a/packages/meshbay-hub/tests/test_session_renewal.py b/packages/meshbay-hub/tests/test_session_renewal.py
new file mode 100644
index 0000000..38db67f
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_session_renewal.py
@@ -0,0 +1,227 @@
+"""
+Staying signed in.
+
+Reported: after an hour or so of watching a film, every action answers "token
+expired or invalid", and the only way back is signing out and in again. Also on
+simply reopening the tab the next day.
+
+Both come from the same place. The access token lasts an hour and the refresh
+token thirty days, but nothing used the second one. `hubFetch` reported a 401 as
+an error like any other, and watching a film is precisely the activity during
+which the hub hears nothing at all — the video travels over WebRTC — so the hour
+ran out with no request to notice.
+
+Underneath that was a worse one. The hub *rotates*: `/v1/users/token/refresh`
+revokes the token presented, returns a replacement, and treats a revoked token
+presented again as theft, revoking the whole family. The client kept only the
+access token out of that response and dropped the new refresh token. So the
+refresh token was spent on first use, and the second attempt did not merely fail
+— it destroyed the family, which is why signing out and back in was the only
+cure.
+
+These run the shipped `hubFetch`, `refreshAccessToken` and `ensureFreshToken`
+against a fake hub that enforces the rotation rule. That rule is the point: a
+stub which accepted the same refresh token twice would have passed against the
+broken client.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+APP = STATIC / "app.js"
+HARNESS = Path(__file__).parent / "harness" / "session_harness.mjs"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not APP.exists(),
+ reason="node or the SPA sources are not available")
+
+
+def _run(scenario: str, app: Path = APP) -> dict:
+ proc = subprocess.run(
+ ["node", str(HARNESS), str(app), json.dumps({"scenario": scenario})],
+ capture_output=True, text=True, timeout=60)
+ assert proc.returncode == 0, f"{proc.stdout}\n{proc.stderr}"
+ return json.loads(proc.stdout.strip().splitlines()[-1])
+
+
+@pytest.fixture(scope="module")
+def broken(tmp_path_factory):
+ """The client as it shipped: the rotated refresh token dropped."""
+ out = tmp_path_factory.mktemp("session") / "broken.js"
+ src = APP.read_text()
+ replaced = src.replace(
+ " refreshToken: data.refresh_token || _auth.refreshToken,",
+ " refreshToken: _auth.refreshToken,")
+ assert replaced != src, (
+ "could not reconstruct the defect — the line it hinged on has moved, "
+ "and the A/B below would be comparing the fix against itself")
+ out.write_text(replaced)
+ return out
+
+
+# ── The reported symptom ──────────────────────────────────────────────────────
+
+def test_an_expired_access_token_renews_itself(app=None):
+ """A tab reopened the next morning, or a film watched for an hour."""
+ r = _run("expired-access")
+ assert not r["signedOut"], "the session was thrown away instead of renewed"
+ assert r["finalCallAccepted"], "the request was not replayed after renewing"
+ assert r["refreshCalls"] == 1
+
+
+def test_renewal_survives_being_needed_more_than_once():
+ """The defect underneath the symptom.
+
+ The hub hands back a new refresh token every time and revokes the old one.
+ Keep the old one and the second renewal is read as theft.
+ """
+ r = _run("repeat")
+ assert not r["familyRevoked"], (
+ f"the family was revoked after presenting {r['refreshTokensPresented']} "
+ "— the rotated token is not being stored")
+ assert not r["signedOut"]
+ assert len(set(r["refreshTokensPresented"])) == len(r["refreshTokensPresented"]), (
+ f"the same refresh token was presented twice: {r['refreshTokensPresented']}")
+
+
+def test_the_defect_is_reproduced_by_dropping_the_rotated_token(broken):
+ """Otherwise the test above proves nothing.
+
+ This is the shipped behaviour, and it ends exactly where the report did:
+ signed out, with nothing but signing back in to be done about it.
+ """
+ r = _run("repeat", app=broken)
+ assert r["familyRevoked"], (
+ "dropping the rotated refresh token no longer breaks anything, so the "
+ "test above is not guarding what it claims")
+ assert r["signedOut"]
+ assert r["refreshTokensPresented"] == ["RT-1", "RT-1"]
+
+
+# ── Renewing exactly once ─────────────────────────────────────────────────────
+
+def test_two_requests_racing_share_one_renewal():
+ """Two 401s at the same instant must not present the token twice.
+
+ They would each renew, the second presenting what the first had already
+ spent — which the hub cannot tell from a stolen token, and answers by
+ revoking the family. The failure mode is worse than the problem.
+ """
+ r = _run("concurrent")
+ assert r["refreshCalls"] == 1, (
+ f"{r['refreshCalls']} renewals for one expiry")
+ assert not r["familyRevoked"]
+ assert r["finalCallAccepted"], "the queued requests were not replayed"
+
+
+def test_a_valid_token_is_left_alone():
+ """Renewing on every call would be its own kind of broken."""
+ r = _run("fresh-access")
+ assert r["refreshCalls"] == 0, "a token with an hour left was renewed anyway"
+ assert r["finalCallAccepted"]
+
+
+# ── When there is genuinely nothing left ──────────────────────────────────────
+
+def test_an_unusable_refresh_token_signs_out_cleanly():
+ """Thirty days later, or after the family was revoked.
+
+ There is nothing to salvage, and the alternative is a session that fails
+ every call for ever while looking signed in.
+ """
+ r = _run("refresh-rejected")
+ assert r["signedOut"], (
+ "the session survived a refusal, so every later call fails with no way "
+ "for the person to understand why")
+ assert r["storedRefreshToken"] is None
+ assert r["refreshCalls"] == 1, "it kept trying a token the hub had refused"
+
+
+# ── The lifetimes themselves ──────────────────────────────────────────────────
+
+def test_the_access_token_outlives_a_film_on_its_own():
+ """Not because it has to — renewal covers any length — but as a floor.
+
+ Renewal makes the access token's life invisible in normal use. This exists
+ for the abnormal one: if renewal fails, a token this short is how long
+ someone has before they notice. An hour was less than a feature film.
+ """
+ from meshbay_hub.config import JWTConfig
+ ttl = JWTConfig().access_token_ttl
+ assert ttl >= 4 * 3600, (
+ f"{ttl / 3600:.1f} h does not cover a long film if renewal fails")
+ assert ttl <= 12 * 3600, (
+ f"{ttl / 3600:.1f} h is a long time for a leaked token to stay usable, "
+ "and it lets the renewal path go a whole day without being exercised — "
+ "which is how it came to be broken without anyone noticing")
+
+
+def test_the_session_is_much_longer_than_the_token():
+ """The two must not be confused: the session is the refresh token."""
+ from meshbay_hub.config import JWTConfig
+ cfg = JWTConfig()
+ assert cfg.refresh_token_ttl >= 7 * 86400
+ assert cfg.refresh_token_ttl > cfg.access_token_ttl * 20, (
+ "the refresh token is barely longer than the access token, so renewing "
+ "buys almost nothing and signing in again comes round just as fast")
+
+
+# ── The margin ────────────────────────────────────────────────────────────────
+
+def test_renewal_happens_before_expiry_not_after():
+ """A margin, so the first click after a long film does not pay for a 401."""
+ src = APP.read_text()
+ import re
+ margin = int(re.search(r"const TOKEN_RENEW_MARGIN_S = (\d+)", src).group(1))
+ assert margin >= 300, (
+ f"{margin} s of margin against a one-hour token is thin: a backgrounded "
+ "tab has its timers throttled and may not check for minutes")
+ assert "visibilitychange" in src, (
+ "nothing re-checks when the tab comes back, which is exactly when the "
+ "token is most likely to have aged out unnoticed")
+
+
+# ── What renewal must not disturb ─────────────────────────────────────────────
+
+def test_renewing_does_not_tear_down_the_webrtc_connection():
+ """The regression that renewal introduced.
+
+ The effect that dials the node listed `token` among its dependencies. That
+ was harmless while a token never changed during a session — it only ran
+ out. Once the session renews itself the string rotates, and the effect tore
+ the connection down and rebuilt it each time. Worst on arrival: a stored
+ token past its life is renewed the instant the page mounts, which is when
+ the group page is negotiating ICE, so the browser abandoned the handshake
+ and the node sat in `connecting` for ever.
+
+ Signing in or out must still re-run it, so the dependency is whether there
+ is a token, not which one.
+ """
+ src = APP.read_text()
+ i = src.index("means tearing down the WebRTC connection")
+ deps = src[i:src.index(");", i)]
+ assert "Boolean(token)" in deps, (
+ "the WebRTC effect depends on the token's value again — every renewal "
+ "drops the connection, and one landing mid-handshake never recovers")
+ assert "[groupId, token," not in deps
+
+
+def test_the_connection_signs_its_offer_with_a_live_token():
+ """The other half: not re-running means the captured token can be stale.
+
+ It signs the offer relayed through the hub, where an expired one is a 401
+ and no connection at all.
+ """
+ src = APP.read_text()
+ connect = src[src.index("const connect = async () => {"):]
+ connect = connect[:connect.index("\n };")]
+ assert "await ensureFreshToken()" in connect, (
+ "the offer is signed with whatever token the effect captured, which is "
+ "no longer refreshed by a re-run")
+ assert "new window.MeshBayTransport('', live)" in connect, (
+ "the transport is built with the captured token rather than the live one")
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index fb92fcd..8d975d7 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -187,8 +187,14 @@ def test_no_setter_survives_the_state_it_belonged_to(app):
# to their object, not to this component.
called = set(re.findall(r"(?<![.\w])(set[A-Z]\w*)\s*\(", app))
builtin = {"setTimeout", "setInterval"}
+ # A `setX` that is a plain function of this module is not an orphan setter:
+ # `setAuth` writes the session to localStorage and has no `useState` behind
+ # it by design. Without this the rule reports every such helper, and a rule
+ # that cries wolf is one someone eventually silences.
+ defined = set(re.findall(r"^(?:async\s+)?function\s+(set[A-Z]\w*)\s*\(", app, re.M))
+ defined |= set(re.findall(r"^\s*const\s+(set[A-Z]\w*)\s*=", app, re.M))
- orphans = sorted(called - declared - imported - builtin)
+ orphans = sorted(called - declared - imported - builtin - defined)
assert not orphans, (
f"setter(s) called with no useState behind them: {orphans} — "
"each one is a ReferenceError the moment that code path runs")