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
|
"""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.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),
"captcha_site_key": _cfg.captcha.site_key if _cfg and _cfg.captcha.enabled else "",
}
@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,
},
}
|