aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/hub.py
blob: 94e9b3c0b18cc1fb22498e7c95daa6cab1f33b7a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
"""Hub info endpoints — /v1/hub/*"""

from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession

from meshbay_common import MNP_VERSION, MHP_VERSION
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,
        },
    }