"""Hub info endpoints — /v1/hub/*""" from fastapi import APIRouter, Depends from meshbay_common import MHP_VERSION, MNP_VERSION from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub import __version__, hub_settings from meshbay_hub.api import federation from meshbay_hub.auth import hub_public_key_pem from meshbay_hub.config import HubConfig from meshbay_hub.db.engine import get_db, get_engine router = APIRouter(prefix="/v1/hub", tags=["hub"]) _cfg: HubConfig | None = None def set_config(cfg: HubConfig) -> None: global _cfg _cfg = cfg @router.get("/info") async def hub_info(db: AsyncSession = Depends(get_db)): engine = get_engine() return { "hub_version": __version__, "mnp_version": MNP_VERSION, "mhp_version": MHP_VERSION, "db_dialect": engine.dialect.name, # Instance policy the SPA needs before drawing the create-group form. # Unauthenticated on purpose: it is not a secret, and the form is # reachable before the group list loads. The hub enforces it regardless # of what any client does with this flag. "allow_public_groups": await hub_settings.public_groups_allowed(db), # Stated, because `mhp_version` above is otherwise a claim this hub does # not honour: every MHP route refuses while federation is closed (see # `api/federation.py`). A peer finds out at `/mhp/info` either way, # which answers 503 — this is the same answer somewhere an operator can # 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"], } @router.get("/pubkey") async def hub_pubkey(): """Hub Ed25519 public key PEM — cached by nodes on first contact.""" return {"pk_hub_pem": hub_public_key_pem().decode()} # What an installed client must be, to talk to this hub. # # Until a client ships, the SPA and the hub deploy together and are always in # sync: a /v1/ response shape changes and app.js is fixed in the same commit. # The moment the interface is installed rather than served, an old client meets # a new hub — for the first time in this project's life — and there is no way to # fix it from here. # # `minimum` refuses; `recommended` warns. Both are stated so a client can tell a # user "update to keep using this" before it becomes "this stopped working". # Raise `minimum` only for a change a client genuinely cannot survive, and # remember store review latency makes that expensive on Android. # Raised on the MNP 3.0 flag day (2026-09-09). A client older than this speaks # MNP 2.x, cannot ask for a transfer lease, and is refused at the node's # handshake with `version_too_old` — a refusal in a protocol vocabulary that # surfaces as "the node will not talk to me". The client checks this field # before connecting and says something a person can act on instead. # # **This first raise does not reach the clients already installed**, and that is # understood rather than overlooked. `package.json` had drifted to "1.0.0" while # every other package was on 0.12.0, so an installed client announces a version # that sorts *above* this minimum and sails through the gate — then meets the # handshake refusal anyway. The operator is updating every client, node and hub # by hand for this flag day, which is what makes that acceptable exactly once. # The gate is in place for the next one, where it will work as intended. MIN_CLIENT_VERSION = "0.13.0" RECOMMENDED_CLIENT_VERSION = "0.13.0" @router.get("/version") async def hub_version(): """Version check endpoint for clients to detect updates.""" return { "hub": __version__, "mnp": MNP_VERSION, "mhp": MHP_VERSION, # A client compares its own version against these before doing anything # else. The browser SPA always matches the hub by construction and can # ignore them. "client": { "minimum": MIN_CLIENT_VERSION, "recommended": RECOMMENDED_CLIENT_VERSION, }, }