From 73ad8e4eb566fe682107fa7e50ef624591199e99 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 15 Sep 2026 02:16:39 +0200 Subject: feat(hub): session lifetime is an admin setting, and a browser signs out when idle Browser idle sign-out (media playback counts as activity; not the desktop app), refresh idle window and maximum session length, in hours. Sign-out now revokes on the hub, and the profile has "sign out everywhere". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm --- packages/meshbay-hub/src/meshbay_hub/api/admin.py | 25 ++++++ packages/meshbay-hub/src/meshbay_hub/api/hub.py | 4 + packages/meshbay-hub/src/meshbay_hub/api/users.py | 97 ++++++++++++++++++++-- packages/meshbay-hub/src/meshbay_hub/config.py | 15 +++- .../meshbay-hub/src/meshbay_hub/hub_settings.py | 40 +++++++++ .../src/meshbay_hub/static/admin-page.js | 34 ++++++++ packages/meshbay-hub/src/meshbay_hub/static/app.js | 94 ++++++++++++++------- .../src/meshbay_hub/static/hub-client.js | 18 +++- .../meshbay-hub/src/meshbay_hub/static/idle.js | 77 +++++++++++++++++ .../src/meshbay_hub/static/locales/de.js | 10 +++ .../src/meshbay_hub/static/locales/en.js | 10 +++ .../src/meshbay_hub/static/locales/es.js | 10 +++ .../src/meshbay_hub/static/locales/fr.js | 10 +++ .../src/meshbay_hub/static/locales/it.js | 10 +++ .../src/meshbay_hub/static/locales/ja.js | 10 +++ .../src/meshbay_hub/static/locales/nl.js | 10 +++ .../src/meshbay_hub/static/locales/pl.js | 10 +++ .../src/meshbay_hub/static/locales/pt-BR.js | 10 +++ .../src/meshbay_hub/static/locales/zh-CN.js | 10 +++ .../src/meshbay_hub/static/profile-page.js | 26 ++++++ 20 files changed, 491 insertions(+), 39 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/idle.js (limited to 'packages/meshbay-hub/src') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index 397b4d9..7ca1e68 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -42,6 +42,8 @@ class SettingsPatchRequest(BaseModel): mail: dict[str, int] | None = None # The sign-in lockout's two numbers, each optional, as for mail. login: dict[str, int] | None = None + # Session lifetime, in hours, each optional. + session: dict[str, int] | None = None # ── Instance settings ──────────────────────────────────────────────────────── @@ -59,6 +61,9 @@ async def _settings_payload(db: AsyncSession) -> dict: "login": await hub_settings.login_limits(db), "login_defaults": dict(hub_settings.LOGIN_DEFAULTS), "login_bounds": {k: list(v) for k, v in hub_settings.LOGIN_BOUNDS.items()}, + "session": await hub_settings.session_limits(db), + "session_defaults": dict(hub_settings.SESSION_DEFAULTS), + "session_bounds": {k: list(v) for k, v in hub_settings.SESSION_BOUNDS.items()}, } @@ -138,6 +143,26 @@ async def admin_patch_settings( )) await db.commit() + if body.session: + unknown = sorted(set(body.session) - set(hub_settings.SESSION_KEYS)) + if unknown: + raise HTTPException( + status_code=422, detail=f"Unknown session setting(s): {unknown}") + changed = [] + for key, value in body.session.items(): + clamped = hub_settings.clamp_session_value(key, value) + await hub_settings.set_raw(db, f"session.{key}", str(clamped)) + changed.append(f"{key}={clamped}") + log.info("Session lifetime changed by %s: %s", + current_user.username, ", ".join(changed)) + db.add(IPLog( + user_id=current_user.id, + event="admin_session_update", + ip_address="admin", + detail=", ".join(changed)[:255], + )) + await db.commit() + return await _settings_payload(db) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index 5a3eb4b..94e9b3c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -40,6 +40,10 @@ async def hub_info(db: AsyncSession = Depends(get_db)): # read it without opening the source. "federation": federation.FEDERATION_ENABLED, "captcha_site_key": _cfg.captcha.site_key if _cfg and _cfg.captcha.enabled else "", + # How long a browser tab stays signed in with nobody at it. The page + # measures it (static/idle.js); the hub only says how long. + "browser_idle_hours": + (await hub_settings.session_limits(db))["browser_idle_hours"], } diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 9150909..a74f6af 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -50,8 +50,28 @@ def set_config(cfg: HubConfig) -> None: def _ttl() -> int: return _cfg.jwt.access_token_ttl if _cfg else 3600 -def _refresh_ttl() -> int: - return _cfg.jwt.refresh_token_ttl if _cfg else 86400 * 30 +async def _refresh_expiry(db: AsyncSession, family_id: str | None = None) -> datetime: + """ + When a refresh token stops being accepted. + + Sliding: each renewal moves it `refresh_idle_hours` past now, so a session + in use stays open — but never past `max_hours` after the sign-in that + started its family. The idle window never drops below the access token's + own life plus an hour: shorter, and a session would lapse between two + renewals of a tab that is being used. + """ + limits = await hub_settings.session_limits(db) + now = datetime.now(timezone.utc) + idle = max(limits["refresh_idle_hours"] * 3600, _ttl() + 3600) + started = now + if family_id is not None: + first = await db.scalar( + select(func.min(RefreshToken.created_at)) + .where(RefreshToken.family_id == family_id)) + if first is not None: + started = first if first.tzinfo else first.replace(tzinfo=timezone.utc) + return min(now + timedelta(seconds=idle), + started + timedelta(hours=limits["max_hours"])) VERIFICATION_TTL = 86400 # 24 hours VERIFICATION_MAX_ATTEMPTS = 10 @@ -134,6 +154,10 @@ class RefreshRequest(BaseModel): refresh_token: str +class LogoutRequest(BaseModel): + refresh_token: str + + # ── Endpoints ───────────────────────────────────────────────────────────────── @router.post("/register", status_code=201) @@ -404,7 +428,7 @@ async def login( raw_rt, rt_hash = generate_refresh_token() family_id = str(uuid.uuid4()) - expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl()) + expires_at = await _refresh_expiry(db) db.add(RefreshToken( user_id=user.id, token_hash=rt_hash, family_id=family_id, expires_at=expires_at, @@ -586,7 +610,7 @@ async def device_auth( group_ids = [gid for (gid,) in memberships.all()] access_token = issue_access_token(user.id, ttl=_ttl(), groups=group_ids) raw_rt, rt_hash = generate_refresh_token() - expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl()) + expires_at = await _refresh_expiry(db) db.add(RefreshToken(user_id=user.id, token_hash=rt_hash, family_id=str(uuid.uuid4()), expires_at=expires_at)) db.add(IPLog(user_id=user.id, event="device_auth", @@ -633,12 +657,21 @@ async def token_refresh( if not user or user.status != "active": raise HTTPException(status_code=401, detail="User not found or suspended") + # The family's first sign-in was longer ago than any session may last. + expires_at = await _refresh_expiry(db, rt.family_id) + if expires_at <= datetime.now(timezone.utc): + await db.execute( + update(RefreshToken) + .where(RefreshToken.family_id == rt.family_id) + .values(revoked=True)) + await db.commit() + raise HTTPException(status_code=401, detail="Session expired") + # Revoke old token rt.revoked = True # Issue new refresh token in the same family new_raw_rt, new_rt_hash = generate_refresh_token() - expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl()) db.add(RefreshToken( user_id=user.id, token_hash=new_rt_hash, family_id=rt.family_id, expires_at=expires_at, @@ -882,6 +915,58 @@ class ChangePasswordRequest(BaseModel): new_auth_key: str +@router.post("/logout") +@limiter.limit("20/minute") +async def logout( + body: LogoutRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """ + End this session on the hub, not only in the browser. + + The browser already forgets its tokens and keys on sign-out; this makes the + refresh token it held worthless to anyone who copied it. The token is the + credential, so no access token is asked for — it may well have expired. An + unknown or already revoked token gets the same answer. + """ + rt = await db.scalar(select(RefreshToken).where( + RefreshToken.token_hash == hash_refresh_token(body.refresh_token))) + if rt is not None: + await db.execute( + update(RefreshToken) + .where(RefreshToken.family_id == rt.family_id) + .values(revoked=True)) + db.add(IPLog(user_id=rt.user_id, event="logout", ip_address=client_ip(request))) + await db.commit() + return {"status": "signed_out"} + + +@router.post("/me/sessions/revoke") +@limiter.limit("5/minute") +async def revoke_all_sessions( + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """ + Sign out everywhere: no refresh token of this account renews any more. + + Access tokens already issued run out on their own, within their lifetime. + A desktop device key is not a session and is left alone — removing the + device is what stops it signing back in. + """ + result = await db.execute( + update(RefreshToken) + .where(RefreshToken.user_id == current_user.id, + RefreshToken.revoked.is_(False)) + .values(revoked=True)) + db.add(IPLog(user_id=current_user.id, event="sessions_revoked", + ip_address=client_ip(request))) + await db.commit() + return {"revoked": result.rowcount} + + @router.post("/password") @limiter.limit("5/minute") async def change_password( @@ -918,7 +1003,7 @@ async def change_password( group_ids = [gid for (gid,) in memberships.all()] access_token = issue_access_token(current_user.id, ttl=_ttl(), groups=group_ids) raw_rt, rt_hash = generate_refresh_token() - expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl()) + expires_at = await _refresh_expiry(db) db.add(RefreshToken( user_id=current_user.id, token_hash=rt_hash, family_id=str(uuid.uuid4()), expires_at=expires_at, diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py index 827538c..876583e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/config.py +++ b/packages/meshbay-hub/src/meshbay_hub/config.py @@ -9,6 +9,7 @@ Priority (highest first): Production config file example: /etc/meshbay/hub.toml """ +import logging import os from dataclasses import dataclass, field from pathlib import Path @@ -18,6 +19,8 @@ try: except ImportError: import tomli as tomllib # type: ignore[no-redef] +log = logging.getLogger(__name__) + DEFAULT_CONFIG_PATHS = [ Path("/etc/meshbay/hub.toml"), Path.home() / ".config" / "meshbay" / "hub.toml", @@ -47,8 +50,9 @@ class HubIdentityConfig: @dataclass class JWTConfig: # 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. + # token is, and the SPA renews against it well before this runs out, so a + # film or a working day never meets this number. How long the session + # lasts is an admin setting (`hub_settings.SESSION_*`), not configuration. # # 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 @@ -58,7 +62,6 @@ class JWTConfig: # 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 @dataclass @@ -161,7 +164,11 @@ def load_config(path: Path | None = None) -> HubConfig: cfg.identity.admin_usernames = list(admins) if jwt := raw.get("jwt", {}): cfg.jwt.access_token_ttl = jwt.get("access_token_ttl", cfg.jwt.access_token_ttl) - cfg.jwt.refresh_token_ttl = jwt.get("refresh_token_ttl", cfg.jwt.refresh_token_ttl) + # One source for the session's length: a value left here would + # otherwise look authoritative and change nothing. + if "refresh_token_ttl" in jwt: + log.warning("%s: [jwt] refresh_token_ttl is ignored — session " + "lifetime is set in the admin panel", p) if ml := raw.get("mail", {}): for name in ( "destination_cooldown_seconds", "destination_daily_cap", diff --git a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py index 1dcff84..c397f58 100644 --- a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py +++ b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py @@ -116,6 +116,46 @@ async def login_limits(db: AsyncSession) -> dict[str, int]: for k in LOGIN_KEYS} +# ── Session lifetime ───────────────────────────────────────────────────────── +# +# `browser_idle_hours`: a browser tab signs itself out after this long with no +# input and nothing playing (`static/idle.js`). The hub cannot measure that — it +# hears a renewal from any open tab, attended or not — so the page does, and +# reads the number from `/v1/hub/info`. The desktop application is exempt: it is +# its owner's machine and signs back in with its device key. +# +# `refresh_idle_hours`: a refresh token unused for this long stops renewing. The +# hub's own backstop, for a token that left the browser it was issued to. +# +# `max_hours`: no session renews past this since its sign-in, used or not. + +SESSION_KEYS = ("browser_idle_hours", "refresh_idle_hours", "max_hours") + +SESSION_DEFAULTS: dict[str, int] = { + "browser_idle_hours": 1, + "refresh_idle_hours": 24, + "max_hours": 720, +} + +SESSION_BOUNDS: dict[str, tuple[int, int]] = { + "browser_idle_hours": (1, 168), # a week + "refresh_idle_hours": (1, 720), # 30 days + "max_hours": (1, 8_760), # a year +} + + +def clamp_session_value(key: str, value: int) -> int: + low, high = SESSION_BOUNDS[key] + return max(low, min(high, int(value))) + + +async def session_limits(db: AsyncSession) -> dict[str, int]: + """The three session numbers, stored value or built-in default.""" + return {k: clamp_session_value( + k, await get_int(db, f"session.{k}", SESSION_DEFAULTS[k])) + for k in SESSION_KEYS} + + async def get_raw(db: AsyncSession, key: str) -> str | None: row = await db.get(HubSetting, key) return row.value if row else None diff --git a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js index c40d240..d5cc7a8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js @@ -17,6 +17,7 @@ export function AdminPage({ token, role }) { // and a rejected one never looks applied. const [mailDraft, setMailDraft] = useState(null); const [loginDraft, setLoginDraft] = useState(null); + const [sessionDraft, setSessionDraft] = useState(null); const [users, setUsers] = useState([]); const [usersTotal, setUsersTotal] = useState(0); const [userSearch, setUserSearch] = useState(''); @@ -45,6 +46,7 @@ export function AdminPage({ token, role }) { setSettings(data); setMailDraft({ ...data.mail }); setLoginDraft({ ...data.login }); + setSessionDraft({ ...data.session }); } catch (e) { setError(e.message); } try { setMailStatus(await hubFetch('/v1/admin/mail', { token })); @@ -64,6 +66,7 @@ export function AdminPage({ token, role }) { // answer rather than left showing a number that was not stored. setMailDraft({ ...data.mail }); setLoginDraft({ ...data.login }); + setSessionDraft({ ...data.session }); if (patch.mail) { try { setMailStatus(await hubFetch('/v1/admin/mail', { token })); @@ -198,6 +201,8 @@ const MAIL_FIELDS = [ const LOGIN_FIELDS = ['max_failures', 'lockout_minutes']; +const SESSION_FIELDS = ['browser_idle_hours', 'refresh_idle_hours', 'max_hours']; + // Only what changed, and only what is a number: an empty field is someone // mid-edit, not a request to set zero. const changedNumbers = (fields, draft, stored) => Object.fromEntries(fields @@ -296,6 +301,35 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist `} + +
+

${t('admin.session_heading')}

+

${t('admin.session_hint')}

+ + ${sessionDraft && SESSION_FIELDS.map(key => html` +
+ ${t('admin.session_' + key)} + setSessionDraft(d => ({ ...d, [key]: e.target.value }))} /> +
+ `)} + + ${canEditSettings && sessionDraft && html` +
+ + +
+ `} +
`} `} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 367774f..a249ccf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -13,8 +13,9 @@ import { HUB, navigate, session, getCachedGroupIndex, _storeBundleKey, _loadBundleKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, ensureFreshToken, hubFetch, - refreshAccessToken, + refreshAccessToken, logoutOnHub, } from './hub-client.js'; +import { startIdleWatch, markActive } from './idle.js'; import { GroupPage } from './group-page.js'; import { SearchPage, ConnectionPool } from './search-page.js'; import { MusicPlayerBar } from './music-player.js'; @@ -741,12 +742,49 @@ function App() { const resolved = resolveTheme(theme); + // The name of the last session. A failed renewal clears the stored session, + // name included, and the device sign-in below needs it to try again. + const lastUsernameRef = useRef(user ? user.username : null); + if (user) lastUsernameRef.current = user.username; + // Set by a deliberate sign-out, which the device key must not undo. + const signedOutRef = useRef(false); + + // Sign in with this device's key. Desktop only: resolves false in a browser, + // or with no key, or with a key the hub no longer knows. + const signInWithDevice = useCallback(async (username) => { + if (!username || !platform.device.available) return false; + try { + const signed = await platform.device.sign(username); + if (!signed) return false; + const data = await hubFetch('/v1/users/auth', { + method: 'POST', + body: { username, timestamp: signed.timestamp, + signature: signed.signature }, + }); + const me = await hubFetch('/v1/users/me', { token: data.access_token }); + const u = { username, userId: me.user_id, token: data.access_token, + refreshToken: data.refresh_token, role: me.role }; + signedOutRef.current = false; + setAuth(u); + setUser(u); + return true; + } catch { + return false; + } + }, []); + // 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. - setAuthChangeListener((auth) => setUser(auth)); + setAuthChangeListener((auth) => { + setUser(auth); + // A renewal the hub refused — the session outlived its idle window, say, + // on a laptop that slept through it. The desktop application signs back + // in with its device key instead of showing the form; a browser has none. + if (!auth && !signedOutRef.current) signInWithDevice(lastUsernameRef.current); + }); // 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 @@ -891,32 +929,14 @@ function App() { if (deviceTried || user) { setDeviceTried(true); return; } let cancelled = false; (async () => { - try { - // `loadAuth` keeps the username even when the tokens in it are stale, - // and `app://meshbay` is a stable origin, so localStorage survives a - // relaunch. A fresh install has nothing here and asks for a passphrase, - // which is right: the first sign-in is what registers the device. - const saved = loadAuth(); - const username = saved && saved.username; - if (!username) return; - const signed = await platform.device.sign(username); - if (!signed) return; - const data = await hubFetch('/v1/users/auth', { - method: 'POST', - body: { username, timestamp: signed.timestamp, - signature: signed.signature }, - }); - const me = await hubFetch('/v1/users/me', { token: data.access_token }); - if (cancelled) return; - const u = { username, userId: me.user_id, token: data.access_token, - refreshToken: data.refresh_token, role: me.role }; - setAuth(u); - setUser(u); - } catch { - // Falls through to the sign-in form, which is the honest outcome. - } finally { - if (!cancelled) setDeviceTried(true); - } + // `loadAuth` keeps the username even when the tokens in it are stale, + // and `app://meshbay` is a stable origin, so localStorage survives a + // relaunch. A fresh install has nothing here and asks for a passphrase, + // which is right: the first sign-in is what registers the device. A + // refusal falls through to the sign-in form, the honest outcome. + const saved = loadAuth(); + await signInWithDevice(saved && saved.username); + if (!cancelled) setDeviceTried(true); })(); return () => { cancelled = true; }; }, []); @@ -977,6 +997,10 @@ function App() { // for the passphrase again. The key is generated and held by the main // process; what travels here is only its public half. await registerThisDevice(token); + // Before the session lands, so the idle watch starting with it does not + // read the last-active time of whoever used this browser before. + markActive(true); + signedOutRef.current = false; // 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. @@ -984,9 +1008,13 @@ function App() { setUser(u); }, logout: () => { + signedOutRef.current = true; // Navigating away leaves transfers running; signing out does not. They // are moving data on tokens that are about to stop being ours. transfers.reset(); + // Revoked on the hub too, so a copy of the refresh token is worth + // nothing. Read before the next line clears it. + logoutOnHub(); setAuth(null); setUser(null); setGroups([]); @@ -994,6 +1022,16 @@ function App() { }, }; + // A browser signs itself out after a stretch with nobody at it (idle.js). Not + // the desktop application: its owner's machine, which the device key would + // sign straight back in anyway. + const idleHours = hubInfo && hubInfo.browser_idle_hours; + const signedInId = user ? user.userId : null; + useEffect(() => { + if (!signedInId || platform.isNative || !idleHours) return undefined; + return startIdleWatch(idleHours * 3600 * 1000, () => authCtx.logout()); + }, [signedInId, idleHours]); + // 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. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js index e72961c..7ba6f92 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js @@ -245,6 +245,22 @@ async function refreshAccessToken() { return _refreshing; } +/** + * Revoke this session's refresh token on the hub. + * + * Fire and forget, and read synchronously: the caller clears the session on the + * next line, and signing out must not wait on the network or fail with it. + */ +function logoutOnHub() { + const refreshToken = _auth && _auth.refreshToken; + if (!refreshToken) return; + platform.apiFetch(HUB + '/v1/users/logout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), + }).catch(() => {}); +} + /** Renew before it bites, rather than after. */ async function ensureFreshToken() { if (!_auth || !_auth.token) return null; @@ -290,5 +306,5 @@ export { cacheGroupIndex, getCachedGroupIndex, getAllCachedIndexes, clearAllCachedIndexes, _storeBundleKey, _loadBundleKey, _storeRecoveryKey, _loadRecoveryKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, - tokenLifeLeft, refreshAccessToken, ensureFreshToken, hubFetch, + tokenLifeLeft, refreshAccessToken, ensureFreshToken, logoutOnHub, hubFetch, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/idle.js b/packages/meshbay-hub/src/meshbay_hub/static/idle.js new file mode 100644 index 0000000..e24f758 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/idle.js @@ -0,0 +1,77 @@ +// Signing a browser out after a stretch with nobody at it. +// +// The hub cannot measure this. It hears a token renewal every few hours from +// any open tab, attended or not, and nothing at all while a film plays over +// WebRTC. So the page decides, and the hub only says how long +// (`browser_idle_hours` in /v1/hub/info). +// +// Activity is input, or any