diff options
Diffstat (limited to 'packages/meshbay-hub/src')
10 files changed, 263 insertions, 64 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index ab6fa07..4960674 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -14,7 +14,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.auth import decrypt_email -from meshbay_hub.api.deps import require_admin, require_moderator +from meshbay_hub.api.deps import require_admin, require_moderator, user_is_admin from meshbay_hub.api.revocation import get_connected_node_count, is_node_connected from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User @@ -196,6 +196,25 @@ async def admin_patch_user( if user.id == current_user.id: raise HTTPException(status_code=400, detail="Cannot modify your own account") + # A moderator suspends and restores accounts — reversible content moderation. + # Changing what someone *is* (their role), and the one irreversible status + # (`revoked`, which is signed and broadcast to every node), are administrative. + # Without this split a moderator could promote an accomplice to admin, or + # revoke every admin, entirely from the moderation role. `admin_delete_user` + # already draws this exact line for the same reason. + privileged = body.role is not None or body.status == "revoked" + if privileged and not user_is_admin(current_user): + raise HTTPException( + status_code=403, + detail="Changing a role, or revoking an account, requires admin rights") + + # An admin's account is not a moderator's to touch at all — not their role, + # not their status. + if user_is_admin(user) and not user_is_admin(current_user): + raise HTTPException( + status_code=403, + detail="Only an admin can change another admin's account") + from meshbay_hub.api.notifications import create_notification if body.role is not None: diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py index 1bf57a4..907a481 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py @@ -72,11 +72,21 @@ async def require_user_scope( return current_user +def user_is_admin(user: User) -> bool: + """Admin by DB role or by the config allow-list. Use inside a handler that + already depends on `require_moderator` but has to draw the admin line for + one field (see `admin_patch_user`).""" + return user.role == "admin" or user.username in _admin_usernames + + +def user_is_moderator(user: User) -> bool: + return user.role in ("moderator", "admin") or user.username in _admin_usernames + + async def require_moderator( current_user: User = Depends(get_current_user), ) -> User: - if current_user.role not in ("moderator", "admin") \ - and current_user.username not in _admin_usernames: + if not user_is_moderator(current_user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Moderator access required") return current_user @@ -85,8 +95,7 @@ async def require_moderator( async def require_admin( current_user: User = Depends(get_current_user), ) -> User: - if current_user.role != "admin" \ - and current_user.username not in _admin_usernames: + if not user_is_admin(current_user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") return current_user diff --git a/packages/meshbay-hub/src/meshbay_hub/api/federation.py b/packages/meshbay-hub/src/meshbay_hub/api/federation.py index 7f1262d..b1e0e30 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/federation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/federation.py @@ -27,7 +27,7 @@ import uuid import jwt from fastapi import APIRouter, Depends, HTTPException, Header from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from meshbay_common import MHP_VERSION @@ -41,6 +41,17 @@ log = logging.getLogger(__name__) router = APIRouter(prefix="/mhp", tags=["federation"]) +# One push may not dump the world, and one peer may not fill the table. +MAX_FEDERATED_GROUPS_PER_PUSH = 500 +MAX_FEDERATED_GROUPS_PER_PEER = 2000 + +# Seen `jti` values for the state-changing MHP endpoints, pruned lazily. The +# sending side that would set an `aud` claim is unbuilt, so audience binding is +# not available; this stops a captured POST /mhp/directory or /mhp/revoke from +# being replayed inside the token's short TTL. GET /mhp/directory is idempotent +# and not covered. +_seen_mhp_jti: dict[str, float] = {} + def _issue_mhp_token(target_hub_id: str) -> str: """Issue a short-lived JWT for authenticating to a peer hub.""" @@ -57,9 +68,15 @@ def _issue_mhp_token(target_hub_id: str) -> str: async def _verify_mhp_token( - token: str, db: AsyncSession, expected_aud: str | None = None, + token: str, db: AsyncSession, *, single_use: bool = False, ) -> dict: - """Verify a JWT from a peer hub using DB-stored public key.""" + """ + Verify a JWT from a peer hub against its DB-stored public key and return the + payload. + + `single_use=True` (the state-changing endpoints) additionally rejects a + replayed `jti` within the token's lifetime. + """ unverified = jwt.decode(token, options={"verify_signature": False}) sender_id = unverified.get("iss") @@ -67,15 +84,22 @@ async def _verify_mhp_token( if not peer: raise PermissionError(f"Unknown hub: {sender_id!r}. Register as peer first.") - options = {} - if expected_aud: - options["audience"] = expected_aud - decoded = jwt.decode( token, peer.pk_hub_pem.encode(), algorithms=["EdDSA"], - options=options, + options={"require": ["exp", "iss"]}, ) + + if single_use: + now = time.time() + for j, exp in list(_seen_mhp_jti.items()): + if exp < now: + _seen_mhp_jti.pop(j, None) + jti = decoded.get("jti", "") + if not jti or jti in _seen_mhp_jti: + raise PermissionError("MHP token replay") + _seen_mhp_jti[jti] = float(decoded.get("exp", now + 300)) + return decoded @@ -142,30 +166,50 @@ async def receive_directory( db: AsyncSession = Depends(get_db), ): try: - await _verify_mhp_token(authorization.removeprefix("Bearer "), db) + payload = await _verify_mhp_token( + authorization.removeprefix("Bearer "), db, single_use=True) except Exception as e: raise HTTPException(status_code=401, detail=str(e)) + # `source_hub` is the signer of this request, never `body.hub_id` — a peer + # does not get to relay or spoof a third hub's groups into our directory. + sender = payload["iss"] + if len(body.groups) > MAX_FEDERATED_GROUPS_PER_PUSH: + raise HTTPException(status_code=413, detail="Too many groups in one push") + from datetime import datetime, timezone now = datetime.now(timezone.utc) + have = await db.scalar( + select(func.count()).select_from(FederatedGroup) + .where(FederatedGroup.source_hub == sender)) or 0 + count = 0 for g in body.groups: - existing = await db.get(FederatedGroup, g["id"]) - if existing: - existing.name = g.get("name", existing.name) - existing.join_policy = g.get("join_policy", existing.join_policy) - existing.updated_at = now + gid = str(g.get("id", ""))[:36] + name = str(g.get("name", ""))[:128] + jp = g.get("join_policy", "invite") + if not gid or jp not in ("invite", "open"): + continue + # A federated id must never shadow a real local group. + if await db.get(Group, gid): + log.warning("Federated id %s collides with a local group — skipped", gid[:8]) + continue + row = await db.get(FederatedGroup, gid) + if row: + if row.source_hub != sender: + continue # only the hub that advertised it may update it + row.name = name or row.name + row.join_policy = jp + row.updated_at = now else: + if have + count >= MAX_FEDERATED_GROUPS_PER_PEER: + break db.add(FederatedGroup( - id=g["id"], - name=g.get("name", ""), - source_hub=body.hub_id, - join_policy=g.get("join_policy", "invite"), - )) + id=gid, name=name, source_hub=sender, join_policy=jp)) count += 1 await db.commit() - log.info("Persisted %d groups from hub %s", count, body.hub_id[:16]) - return {"accepted": count, "from_hub": body.hub_id} + log.info("Persisted %d groups from hub %s", count, sender[:16]) + return {"accepted": count, "from_hub": sender} # ── Revocation propagation ──────────────────────────────────────────────────── @@ -179,15 +223,43 @@ async def receive_revocation( authorization: str = Header(...), db: AsyncSession = Depends(get_db), ): + """ + Act on a revocation from a peer hub. + + This does **not** reach local nodes: nothing here hosts a federated group, + and a local node would reject a token signed by another hub's key anyway + (that path was a silent no-op). What a peer may legitimately revoke is a + group *it advertised to us* — so this prunes our copy of the peer's + directory. Users are per-hub; a peer does not get to revoke ours. + """ try: - await _verify_mhp_token(authorization.removeprefix("Bearer "), db) + payload = await _verify_mhp_token( + authorization.removeprefix("Bearer "), db, single_use=True) except Exception as e: raise HTTPException(status_code=401, detail=str(e)) - from meshbay_hub.api.revocation import broadcast_revocation - sent = await broadcast_revocation(body.token) - log.info("Propagated revocation to %d local nodes", sent) - return {"propagated_to": sent} + sender = payload["iss"] + peer = await db.get(HubPeer, sender) + try: + inner = jwt.decode( + body.token, peer.pk_hub_pem.encode(), algorithms=["EdDSA"], + options={"verify_exp": False}) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Bad revocation token: {e}") + + if inner.get("type") != "revocation" or inner.get("target") != "group": + return {"pruned": 0, "note": "federation may only revoke groups it advertised"} + + target_id = inner.get("target_id", "") + row = await db.get(FederatedGroup, target_id) + pruned = 0 + if row and row.source_hub == sender: + await db.delete(row) + await db.commit() + pruned = 1 + log.info("Federated group %s revoked by %s (pruned=%d)", + target_id[:8], sender[:16], pruned) + return {"pruned": pruned} # ── Peer management (admin) ─────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index fdb0444..07d3ec0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -589,6 +589,13 @@ async def add_group_member( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + # `get_current_user`, not `require_user_scope`: the node calls this after a + # CLI `member invite` so the group becomes visible in the invitee's SPA + # (commit 0443cf8). The node authenticates with a node-scoped token, and the + # `group.admin_id == current_user.id` check below is the real guard — a node + # can only touch its own operator's groups, adding an already-registered + # account. (Third-review M6 proposed tightening this to `require_user_scope`; + # that broke the CLI invite flow and was reverted — see the review doc.) group = await db.get(Group, group_id) if not group: raise HTTPException(status_code=404, detail="Group not found") diff --git a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py index 853f255..0939eec 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py @@ -1,13 +1,16 @@ """ MeshBay Hub — moderation endpoints. -Public reporting flow: - POST /v1/reports — report a content hash (no auth required) +Reporting flow: + POST /v1/reports — report a content hash (sign-in required) - Thresholds: - 1st report → logged, node admin notified (future: push notification) - 2nd report → content hash added to blocklist automatically - 3rd+ report → logged as repeat offense (escalation for human review) + Thresholds (counted as DISTINCT reporting accounts, not raw rows): + < AUTO_BLOCK_THRESHOLD distinct reporters → logged + >= AUTO_BLOCK_THRESHOLD distinct reporters → hash added to the blocklist + + The flow only runs while the hub brokers public content: with public groups + switched off instance-wide there is nothing here to serve a reported hash from, + so it is refused rather than left open as an unauthenticated write surface. Admin endpoints: GET /v1/admin/blocklist — list blocked hashes @@ -27,7 +30,9 @@ from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from meshbay_hub import hub_settings from meshbay_hub.api.deps import get_current_user, require_admin +from meshbay_hub.api.middleware import limiter from meshbay_hub.api.netutil import client_ip from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import ContentBlocklist, ContentReport, User @@ -36,7 +41,11 @@ log = logging.getLogger(__name__) router = APIRouter(tags=["moderation"]) -AUTO_BLOCK_THRESHOLD = 2 # reports before automatic block +# Distinct reporting accounts before a hash is auto-blocked. Kept low for a +# responsive community signal, but note it is only as strong as account +# creation: while a bot can register freely (see the reCAPTCHA gap), the real +# control is the admin reviewing `GET /v1/admin/blocklist` and the audit log. +AUTO_BLOCK_THRESHOLD = 3 # ── Models ──────────────────────────────────────────────────────────────────── @@ -56,34 +65,58 @@ class BlocklistAddRequest(BaseModel): # ── Public endpoints ────────────────────────────────────────────────────────── @router.post("/v1/reports", status_code=201) +@limiter.limit("10/hour") async def report_content( body: ReportRequest, request: Request, + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Report a content hash. No authentication required.""" + """ + Report a public content hash for moderation. + + Sign-in is required. It used to be anonymous, which made it a censorship + primitive: two unauthenticated POSTs naming any blake3 id auto-added it to + the blocklist that nodes enforce, network-wide, with manual admin removal the + only undo. The threshold now counts *distinct reporting accounts*, one vote + per account per hash. + + Refused entirely when the hub has public groups switched off: nothing here + brokers public content then, nothing syncs the blocklist, and an open write + endpoint would only be abuse surface. + """ + if not await hub_settings.public_groups_allowed(db): + raise HTTPException( + status_code=403, + detail="This hub does not broker public content, so there is nothing to report here.") + if len(body.content_hash) != 64 or not all(c in "0123456789abcdef" for c in body.content_hash): raise HTTPException(status_code=422, detail="content_hash must be 64 hex chars (blake3)") - ip = client_ip(request) + # One vote per account per hash — a single reporter must not be able to walk + # the threshold up on their own by posting repeatedly. + already = await db.scalar( + select(ContentReport.id).where( + ContentReport.content_hash == body.content_hash, + ContentReport.reporter_id == current_user.id)) - # Count existing reports for this hash - count_result = await db.execute( - select(func.count()).where(ContentReport.content_hash == body.content_hash)) - count = count_result.scalar_one() + if not already: + db.add(ContentReport( + content_hash=body.content_hash, + reporter_id=current_user.id, + group_id=body.group_id, + reason=body.reason, + detail=body.detail, + ip_address=client_ip(request), + )) + await db.flush() - report = ContentReport( - content_hash=body.content_hash, - group_id=body.group_id, - reason=body.reason, - detail=body.detail, - ip_address=ip, - ) - db.add(report) + distinct_reporters = await db.scalar( + select(func.count(func.distinct(ContentReport.reporter_id))) + .where(ContentReport.content_hash == body.content_hash)) or 0 - action = "logged" - if count + 1 >= AUTO_BLOCK_THRESHOLD: - # Check if already blocked + action = "already_reported" if already else "logged" + if distinct_reporters >= AUTO_BLOCK_THRESHOLD: existing = await db.get(ContentBlocklist, body.content_hash) if not existing: db.add(ContentBlocklist( @@ -92,13 +125,14 @@ async def report_content( added_by="auto", )) action = "auto_blocked" - log.warning("Content auto-blocked after %d reports: %s", count + 1, body.content_hash[:16]) + log.warning("Content auto-blocked after %d distinct reporters: %s", + distinct_reporters, body.content_hash[:16]) await db.commit() return { "status": action, "content_hash": body.content_hash, - "report_count": count + 1, + "report_count": distinct_reporters, "threshold": AUTO_BLOCK_THRESHOLD, } diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 559cfa6..9b70b59 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -150,8 +150,13 @@ async def register( return {"user_id": found.id, "email_verification_required": True} raise HTTPException(status_code=409, detail="Username already taken") - # Captcha gate — web path only (native clients send auth_key) - if _cfg and _cfg.captcha.enabled and not body.auth_key: + # Captcha gate — every fresh registration when a captcha is configured, with + # no client carve-out. The earlier `and not body.auth_key` exempted anything + # that sent an `auth_key`, which is *every* real client (the browser sends it + # too, from the password split) — so the check was off for everyone, and a + # bot skipped it by sending the field. The desktop client is Chromium and + # renders the same widget, so it has no need of an exemption either. + if _cfg and _cfg.captcha.enabled: await _verify_captcha_or_raise(body.captcha_token, request) # Email uniqueness (only active or pending accounts) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index 0821809..6dfd3ed 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -22,7 +22,8 @@ STATIC_DIR = Path(__file__).parent.parent / "static" router = APIRouter(tags=["webapp"]) # Assets the shell pulls in, in load order. Everything else is imported by -# app.js and rides on the same query string via window.__MB_ASSET_V. +# app.js from a relative path, which inherits the `/a/<hash>/` prefix the shell +# loaded app.js under — so the whole module graph moves together. # Every module the page loads. A file missing from here is a file whose change # does not move the URL, so a browser holding the old one never asks for it — # which is the failure this list exists to prevent, and it is silent. @@ -77,6 +78,33 @@ ASSET_V = _asset_version() _NO_STORE = {"Cache-Control": "no-store"} +# Content-Security-Policy for the whole hub, applied by a middleware in app.py. +# +# This is the *same* policy the desktop client's protocol handler already sends +# for these exact UI files (`meshbay-client/src/main.js`), plus the two reCAPTCHA +# hosts the sign-up widget loads its script, challenge iframe and images from. +# `'unsafe-inline'` is style-only — htm/preact set inline `style=` attributes +# everywhere; nothing inline executes, and the shell below carries no inline +# `<script>`. `'wasm-unsafe-eval'` is required for the Argon2id WASM. The hub's +# own origin is deliberately absent from `script-src`: a response it returns is +# never executed, which is the point of T3. +_RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com" +CSP = "; ".join([ + "default-src 'none'", + f"script-src 'self' 'wasm-unsafe-eval' {_RECAPTCHA_SRC}", + "style-src 'self' 'unsafe-inline'", + f"img-src 'self' data: blob: {_RECAPTCHA_SRC}", + "media-src 'self' blob:", + "font-src 'self'", + "connect-src 'self' https: wss:", + "worker-src 'self'", + f"frame-src {_RECAPTCHA_SRC}", + "frame-ancestors 'none'", + "base-uri 'none'", + "form-action 'none'", +]) + + @router.get("/app", response_class=HTMLResponse) async def app_root(): return HTMLResponse(_HTML, headers=_NO_STORE) @@ -109,7 +137,6 @@ _HTML = """\ and app.js's own relative imports inherit the prefix, which is the only way the module graph is guaranteed not to be a mixture of two builds. See _asset_version() and VersionedStatics. --> - <script>window.__MB_ASSET_V = "{v}";</script> <!-- Argon2id (WebAssembly, inlined) — WebCrypto has no memory-hard KDF, and the keypair bundle needs one: it is protected by the passphrase alone and sits on every node its owner joins (C4). Vendored, see static/vendor/PROVENANCE.md --> diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 76d7ec0..2daa55b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -35,7 +35,7 @@ from meshbay_hub.api.relay import router as relay_router from meshbay_hub.api.signaling import router as signaling_router from meshbay_hub.api.admin import router as admin_router from meshbay_hub.api.notifications import router as notifications_router -from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR, ASSET_V +from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR, ASSET_V, CSP from meshbay_hub.api.middleware import limiter @@ -142,6 +142,21 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + @app.middleware("http") + async def _security_headers(request, call_next): + """ + Second-review L5, third-review M5: the SPA shell and its assets went out + with no CSP and no other protective headers. This adds them everywhere — + `webapp.CSP` is the same policy the desktop client already enforces on + these exact files. `setdefault` so a route that sets its own wins. + """ + response = await call_next(request) + response.headers.setdefault("Content-Security-Policy", CSP) + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + response.headers.setdefault("X-Frame-Options", "DENY") + return response + # Routers (webapp last — catches / before API routes) app.include_router(hub_router) app.include_router(users_router) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js index df08bc4..4c00137 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -237,8 +237,11 @@ export function RegisterPage() { const rk = window.MeshBayKeys.generateRecoveryKey(); // `name` (trimmed), not the raw field: the hub stores the trimmed // username and every key derivation must fold in the same string. + // `captcha.token` rides along — the submit button is already disabled + // until it is set when a captcha is configured (see the form below). await window.MeshBayKeys.registerUser( - name, email, password, emailRecovery ? rk.mnemonic : null); + name, email, password, emailRecovery ? rk.mnemonic : null, + captcha.token); setRecoveryMnemonic(rk.mnemonic); session.recoveryKey = await window.MeshBayKeys.deriveRecoveryKey(rk.mnemonic, name); @@ -257,6 +260,10 @@ export function RegisterPage() { } } catch (err) { setError(err.message); + // A reCAPTCHA token is single-use: after a failed attempt (name taken, + // e-mail in use…) it is spent, so clear it and make the user solve a + // fresh one before the next try. No-op when no captcha is configured. + captcha.reset(); } finally { setLoading(false); } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index 0aaa6a5..a540a94 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -276,7 +276,7 @@ async function decryptBundle(bundleB64, password, username) { * * Returns the raw private keys for immediate use after registration. */ -async function registerUser(username, email, password, recoveryMnemonic) { +async function registerUser(username, email, password, recoveryMnemonic, captchaToken) { // No keypair here any more. Identity keys are per node: one is generated the // first time this account joins a given node, encrypted under the passphrase, // and left with that node. So an operator who cracks what sits on their own @@ -291,6 +291,10 @@ async function registerUser(username, email, password, recoveryMnemonic) { // appends it to the verification e-mail and stores it nowhere // (docs/auth-confirm.md §4.4). Omitted when they chose to save it themselves. if (recoveryMnemonic) payload.recovery_key = recoveryMnemonic; + // reCAPTCHA response, when the hub has a captcha configured. The widget lives + // in RegisterPage (auth-page.js); this function just forwards its token. A + // hub with no captcha configured sends nothing and the server does not check. + if (captchaToken) payload.captcha_token = captchaToken; const resp = await hubCall('/v1/users/register', { method: 'POST', |