summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-28 02:51:00 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-28 02:51:00 +0200
commitb5b4f188a39fc96c4d32e67151e067b1add6dcfc (patch)
treece0c4ff78044a56c0882d7ba94fbea436f0e83c3
parente1f1b65cfac031096e4bae24ccf102ca0dbb86d9 (diff)
downloadmeshbay-b5b4f188a39fc96c4d32e67151e067b1add6dcfc.tar.gz
feat(hub): let a hub admin disable public groups instance-wide
A new General tab in Administration carries one switch, allow_public_groups, stored in a hub_settings key/value table (runtime-editable, unlike hub.toml). Default is on; an absent row means on, so an upgrade changes nothing. Enforcement is server-side on every hub-mediated path, not just the SPA: - create_group refuses visibility=public (403), staff included - list_public_groups the directory returns nothing (local + federated) - join_group open-joining a public group is refused - group_online_nodes a non-member of a public group is handed no node - signaling.webrtc_offer drops the "node hosts an open group" fallback - federation.export_directory advertises nothing to peer hubs The switch is read live, so flipping it back restores every path. Existing members of a group that predates the switch keep their membership row and their access — this is plan A, not a purge. GET /v1/hub/info exposes the flag (unauthenticated) so the create-group form and the sidebar's "Public groups" link render correctly. Also in the admin Groups tab: a Revoke action beside Suspend. Suspend is the reversible hub flag; Revoke calls POST /v1/admin/revoke, which sets status=revoked and broadcasts a signed revocation every node enforces (denylist + dropped live sessions). It is confirm-guarded and names the group. And a message fix the revoke work surfaced: group_online_nodes, join_group and webrtc_offer answered "Group is suspended" for any non-active status. They now report the real state, so a member of a revoked group is told "Group is revoked" rather than something reversible-sounding. Tests: test_public_groups_toggle.py (10) covers the switch end to end and the five enforcement paths; test_revocation.py gains the status-message assertion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py50
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/federation.py14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/signaling.py33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b1c2d3e4f5a6_add_hub_settings.py34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/hub_settings.py44
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js187
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js7
-rw-r--r--packages/meshbay-hub/tests/test_public_groups_toggle.py269
-rw-r--r--packages/meshbay-hub/tests/test_revocation.py34
21 files changed, 729 insertions, 75 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index 7ee05ca..4141c79 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -18,6 +18,7 @@ from meshbay_hub.api.deps import require_admin, require_moderator
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
+from meshbay_hub import hub_settings
log = logging.getLogger(__name__)
@@ -35,6 +36,55 @@ class GroupPatchRequest(BaseModel):
status: str | None = None
+class SettingsPatchRequest(BaseModel):
+ allow_public_groups: bool | None = None
+
+
+# ── Instance settings ────────────────────────────────────────────────────────
+
+def _settings_payload(allow_public_groups: bool) -> dict:
+ return {"allow_public_groups": allow_public_groups}
+
+
+@router.get("/settings")
+async def admin_get_settings(
+ current_user: User = Depends(require_moderator),
+ db: AsyncSession = Depends(get_db),
+):
+ """Instance-wide policy an admin controls from the panel. Moderators may read."""
+ return _settings_payload(await hub_settings.public_groups_allowed(db))
+
+
+@router.patch("/settings")
+async def admin_patch_settings(
+ body: SettingsPatchRequest,
+ current_user: User = Depends(require_admin),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Change instance policy. Admin only — moderators get the read above.
+
+ The enforcement lives where the thing being restricted happens (public-group
+ creation is refused in `groups.create_group`), so flipping this here is the
+ whole change: a client that keeps drawing the option still cannot use it.
+ """
+ if body.allow_public_groups is not None:
+ await hub_settings.set_raw(
+ db, hub_settings.ALLOW_PUBLIC_GROUPS,
+ "true" if body.allow_public_groups else "false")
+ log.info("Instance setting allow_public_groups=%s by %s",
+ body.allow_public_groups, current_user.username)
+ db.add(IPLog(
+ user_id=current_user.id,
+ event="admin_settings_update",
+ ip_address="admin",
+ detail=f"allow_public_groups={body.allow_public_groups}",
+ ))
+ await db.commit()
+
+ return _settings_payload(await hub_settings.public_groups_allowed(db))
+
+
# ── Stats ────────────────────────────────────────────────────────────────────
@router.get("/stats")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/federation.py b/packages/meshbay-hub/src/meshbay_hub/api/federation.py
index b6b4acf..7f1262d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/federation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/federation.py
@@ -31,7 +31,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_common import MHP_VERSION
-from meshbay_hub import __version__
+from meshbay_hub import __version__, hub_settings
from meshbay_hub.api.deps import require_admin
from meshbay_hub.auth import _hub_id, _hub_sk_pem, hub_public_key_pem
from meshbay_hub.db.engine import get_db
@@ -104,9 +104,15 @@ async def export_directory(
except Exception as e:
raise HTTPException(status_code=401, detail=str(e))
- result = await db.execute(
- select(Group).where(Group.visibility == "public", Group.status == "active"))
- groups = result.scalars().all()
+ # A hub with public groups switched off advertises nothing to its peers —
+ # the local directory is empty (groups.list_public_groups), and the exported
+ # one has to match or peers keep showing groups this hub no longer serves.
+ if not await hub_settings.public_groups_allowed(db):
+ groups = []
+ else:
+ result = await db.execute(
+ select(Group).where(Group.visibility == "public", Group.status == "active"))
+ groups = result.scalars().all()
return {
"hub_id": _hub_id,
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index e78d950..91aa9c4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from sqlalchemy import func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
+from meshbay_hub import hub_settings
from meshbay_hub.api.deps import get_current_user, require_user_scope
from meshbay_hub.api.netutil import client_ip
from meshbay_hub.db.engine import get_db
@@ -107,7 +108,17 @@ async def group_online_nodes(
if not group:
raise HTTPException(status_code=404, detail="Group not found")
if group.status != "active":
- raise HTTPException(status_code=403, detail="Group is suspended")
+ # Name the real state: "suspended" is reversible, "revoked" is a signed
+ # instruction every node enforces. The client shows this string as-is.
+ raise HTTPException(status_code=403, detail=f"Group is {group.status}")
+
+ # A public group is normally readable by anyone — that is the point of it.
+ # But when the hub has public groups switched off, an existing one keeps
+ # working only for the people already in it: no non-member gets handed a
+ # node to connect to. Members always have a row here, so they are unaffected.
+ if group.visibility == "public" and not await hub_settings.public_groups_allowed(db):
+ if not await db.get(GroupMember, (group_id, current_user.id)):
+ raise HTTPException(status_code=403, detail="Not a member of this group")
node_ids = get_online_nodes_for_group(group_id)
nodes = []
@@ -127,6 +138,12 @@ async def list_public_groups(
include_federated: bool = True,
):
"""List/search public groups — local and optionally federated. No auth required."""
+ # The hub admin can switch public groups off for the whole instance. When
+ # they have, there is no directory at all — not the local groups that predate
+ # the switch, and not the federated ones a peer still advertises. The switch
+ # is read live, so flipping it back brings the directory straight back.
+ if not await hub_settings.public_groups_allowed(db):
+ return {"groups": [], "total": 0}
# Unhosted groups are absent from the directory: until a node announces it,
# a group has no files, no key and nothing to connect to, so listing it only
# produces a dead end. Its owner still sees it in /mine while they set it up.
@@ -272,9 +289,14 @@ async def join_group(
if not group:
raise HTTPException(status_code=404, detail="Group not found")
if group.status != "active":
- raise HTTPException(status_code=403, detail="Group is not active")
+ raise HTTPException(status_code=403, detail=f"Group is {group.status}")
if group.join_policy != "open":
raise HTTPException(status_code=403, detail="Group does not allow open joining")
+ if group.visibility == "public" and not await hub_settings.public_groups_allowed(db):
+ # The group predates the switch; open joining is off with it. Existing
+ # members keep their row and their access.
+ raise HTTPException(
+ status_code=403, detail="This hub does not allow joining public groups.")
existing = await db.get(GroupMember, (group_id, current_user.id))
if existing:
@@ -365,6 +387,14 @@ async def create_group(
detail="A private group is invite-only. Make it public if you want "
"anyone to be able to join.")
if body.visibility == "public":
+ if not await hub_settings.public_groups_allowed(db):
+ # Instance policy, set by a hub admin in the panel. Absolute — staff
+ # included — because the way back is to re-enable it, not to slip
+ # past it. A client that still shows the "open" choice lands here.
+ raise HTTPException(
+ status_code=403,
+ detail="This hub does not allow public groups. Create the group "
+ "as private — you can still invite people to it.")
await _check_public_group_quota(db, current_user)
desc = (body.description or "")[:512] if body.description else None
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
index 4aef93d..8692995 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -1,22 +1,29 @@
"""Hub info endpoints — /v1/hub/*"""
-from fastapi import APIRouter
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
from meshbay_common import MNP_VERSION, MHP_VERSION
-from meshbay_hub import __version__
+from meshbay_hub import __version__, hub_settings
from meshbay_hub.auth import hub_public_key_pem
-from meshbay_hub.db.engine import get_engine
+from meshbay_hub.db.engine import get_db, get_engine
router = APIRouter(prefix="/v1/hub", tags=["hub"])
@router.get("/info")
-async def hub_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),
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
index 84c1167..a8feae8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
@@ -22,6 +22,7 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from meshbay_hub import hub_settings
from meshbay_hub.api.deps import get_current_user
from meshbay_hub.api.middleware import limiter
from meshbay_hub.db.engine import get_db
@@ -93,6 +94,11 @@ async def webrtc_offer(
# The caller must share at least one active group with the target node,
# OR the node must host at least one open-join group (public groups admit
# anyone — the node's MNP handshake handles authorization).
+ #
+ # That second path is exactly what "public groups" means, so it is gated by
+ # the instance switch: with public groups off, a non-member is not brokered a
+ # connection to a node just because it happens to host an open group. Members
+ # of that group are unaffected — they match `shared` below.
node_group_ids = set(_node_groups.get(node_id, []))
if node_group_ids:
result = await db.execute(
@@ -102,19 +108,24 @@ async def webrtc_offer(
))
shared = [gid for (gid,) in result.all()]
if not shared:
- has_open = await db.execute(
- select(Group.id).where(
- Group.id.in_(node_group_ids),
- Group.join_policy == "open",
- Group.status == "active",
- ))
- if not has_open.first():
+ has_open = None
+ if await hub_settings.public_groups_allowed(db):
+ has_open = (await db.execute(
+ select(Group.id).where(
+ Group.id.in_(node_group_ids),
+ Group.join_policy == "open",
+ Group.status == "active",
+ ))).first()
+ if not has_open:
raise HTTPException(status_code=403, detail="Not a member of any group on this node")
else:
- active = await db.execute(
- select(Group.id).where(Group.id.in_(shared), Group.status == "active"))
- if not active.first():
- raise HTTPException(status_code=403, detail="Group is not active")
+ statuses = set((await db.execute(
+ select(Group.status).where(Group.id.in_(shared)))).scalars().all())
+ if "active" not in statuses:
+ # Report the strongest state present — "revoked" is the signed,
+ # node-enforced one; "suspended" is the reversible hub flag.
+ state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended")
+ raise HTTPException(status_code=403, detail=f"Group is {state}")
if _pending_per_user.get(current_user.id, 0) >= MAX_PENDING_PER_USER:
raise HTTPException(status_code=429, detail="Too many pending connections")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b1c2d3e4f5a6_add_hub_settings.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b1c2d3e4f5a6_add_hub_settings.py
new file mode 100644
index 0000000..13f2b5c
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b1c2d3e4f5a6_add_hub_settings.py
@@ -0,0 +1,34 @@
+"""add_hub_settings
+
+Revision ID: b1c2d3e4f5a6
+Revises: a7b8c9d0e1f2
+Create Date: 2026-08-28 12:00:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'b1c2d3e4f5a6'
+down_revision: Union[str, Sequence[str], None] = 'a7b8c9d0e1f2'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'hub_settings',
+ sa.Column('key', sa.String(64), nullable=False),
+ sa.Column('value', sa.Text(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True),
+ server_default=sa.func.now(), nullable=False),
+ sa.PrimaryKeyConstraint('key'),
+ )
+ # No seed rows: an absent key is the default, and writing "true" here would
+ # make a later change of default invisible to an already-migrated hub.
+
+
+def downgrade() -> None:
+ op.drop_table('hub_settings')
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index 3966bdc..0c337c1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -299,6 +299,26 @@ class UserPreference(Base):
value: Mapped[str] = mapped_column(Text, nullable=False)
+class HubSetting(Base):
+ """
+ Instance-wide settings an admin changes at runtime from the panel.
+
+ Deliberately a key/value table rather than fields in `hub.toml`: the config
+ file is read once at boot and editing it means an SSH session and a restart,
+ which is not what "toggle this from the admin page" means. Anything here is a
+ policy the running hub can change on itself.
+
+ Absent key ⇒ the built-in default (see `meshbay_hub.hub_settings`). An older
+ hub with no row behaves exactly as it did before the setting existed.
+ """
+ __tablename__ = "hub_settings"
+
+ key: Mapped[str] = mapped_column(String(64), primary_key=True)
+ value: Mapped[str] = mapped_column(Text, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=_now, onupdate=_now)
+
+
class IPLog(Base):
"""
Connection log for legal compliance.
diff --git a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
new file mode 100644
index 0000000..280d1e2
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
@@ -0,0 +1,44 @@
+"""
+Instance-wide settings stored in the `hub_settings` table.
+
+One reader per concern, so callers never touch raw strings or key names. A
+missing row means the built-in default — an upgrade never changes behaviour on
+its own, and a downgrade that drops the table just returns to defaults.
+"""
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from meshbay_hub.db.models import HubSetting
+
+# Whether a member may create a group that is listed in the public directory and
+# open for anyone to join. Off makes the hub private-groups-only.
+ALLOW_PUBLIC_GROUPS = "allow_public_groups"
+
+_DEFAULTS: dict[str, str] = {
+ ALLOW_PUBLIC_GROUPS: "true",
+}
+
+
+async def get_raw(db: AsyncSession, key: str) -> str | None:
+ row = await db.get(HubSetting, key)
+ return row.value if row else None
+
+
+async def set_raw(db: AsyncSession, key: str, value: str) -> None:
+ """Upsert. The caller owns the commit."""
+ row = await db.get(HubSetting, key)
+ if row:
+ row.value = value
+ else:
+ db.add(HubSetting(key=key, value=value))
+
+
+async def get_bool(db: AsyncSession, key: str) -> bool:
+ raw = await get_raw(db, key)
+ if raw is None:
+ raw = _DEFAULTS.get(key, "false")
+ return raw == "true"
+
+
+async def public_groups_allowed(db: AsyncSession) -> bool:
+ return await get_bool(db, ALLOW_PUBLIC_GROUPS)
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 446360c..a380033 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -299,7 +299,8 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount,
// ── Sidebar ──────────────────────────────────────────────────────────────────
-function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, hasNodeKey }) {
+function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, hasNodeKey,
+ allowPublicGroups = true }) {
const isStaff = role === 'moderator' || role === 'admin';
return html`
<aside class="sidebar ${menuOpen ? 'open' : ''}">
@@ -312,8 +313,9 @@ function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, ha
`}
<div class="sidebar-section">
<div class="sidebar-heading">${t('sidebar.discover')}</div>
- <a class="sidebar-item ${route === '/explore' ? 'active' : ''}"
- href="#/explore"><${Icon} name="globe" /> ${t('sidebar.public_groups')}</a>
+ ${allowPublicGroups && html`
+ <a class="sidebar-item ${route === '/explore' ? 'active' : ''}"
+ href="#/explore"><${Icon} name="globe" /> ${t('sidebar.public_groups')}</a>`}
<a class="sidebar-item ${route === '/search' ? 'active' : ''}"
href="#/search"><${Icon} name="search" /> ${t('sidebar.search')}</a>
</div>
@@ -740,9 +742,12 @@ function CreateGroupPage(props) {
return html`<${CreateGroupFormSimple} ...${props} />`;
}
-function CreateGroupFormSimple({ token, onCreated }) {
+function CreateGroupFormSimple({ token, onCreated, allowPublicGroups = true }) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
+ // Only ever anything other than 'invite' when the hub allows public groups —
+ // the join-policy section is not rendered otherwise, so there is nothing to
+ // set it 'open'.
const [joinPolicy, setJoinPolicy] = useState('invite');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
@@ -793,29 +798,31 @@ function CreateGroupFormSimple({ token, onCreated }) {
</div>
</div>
- <div class="settings-section">
- <h3 class="settings-heading">${t('create_group.join_policy')}</h3>
- <div class="choice-list">
- <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}">
- <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'}
- onChange=${() => setJoinPolicy('invite')} />
- <${Icon} name="lock" cls="choice-icon" />
- <span class="choice-text">
- <span class="choice-title">${t('create_group.invite')}</span>
- <span class="choice-desc">${t('create_group.invite_desc')}</span>
- </span>
- </label>
- <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}">
- <input type="radio" name="join_policy" checked=${joinPolicy === 'open'}
- onChange=${() => setJoinPolicy('open')} />
- <${Icon} name="globe" cls="choice-icon" />
- <span class="choice-text">
- <span class="choice-title">${t('create_group.open')}</span>
- <span class="choice-desc">${t('create_group.open_desc')}</span>
- </span>
- </label>
+ ${allowPublicGroups && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('create_group.join_policy')}</h3>
+ <div class="choice-list">
+ <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}">
+ <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'}
+ onChange=${() => setJoinPolicy('invite')} />
+ <${Icon} name="lock" cls="choice-icon" />
+ <span class="choice-text">
+ <span class="choice-title">${t('create_group.invite')}</span>
+ <span class="choice-desc">${t('create_group.invite_desc')}</span>
+ </span>
+ </label>
+ <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}">
+ <input type="radio" name="join_policy" checked=${joinPolicy === 'open'}
+ onChange=${() => setJoinPolicy('open')} />
+ <${Icon} name="globe" cls="choice-icon" />
+ <span class="choice-text">
+ <span class="choice-title">${t('create_group.open')}</span>
+ <span class="choice-desc">${t('create_group.open_desc')}</span>
+ </span>
+ </label>
+ </div>
</div>
- </div>
+ `}
<button class="btn-primary" type="submit" disabled=${loading}>
${loading ? t('create_group.creating') : t('create_group.submit')}
@@ -827,7 +834,7 @@ function CreateGroupFormSimple({ token, onCreated }) {
// ── Create Group Wizard (Electron-only) ─────────────────────────────────────
-function CreateGroupWizard({ token, username, onCreated }) {
+function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = true }) {
const [step, setStep] = useState(0); // 0=node check, 1=details, 2=setup, 3=done
const [nodeStatus, setNodeStatus] = useState(null); // null=loading, object=result
const [nodeStarting, setNodeStarting] = useState(false);
@@ -836,6 +843,8 @@ function CreateGroupWizard({ token, username, onCreated }) {
// Step 1 fields
const [name, setName] = useState('');
const [description, setDescription] = useState('');
+ // See CreateGroupFormSimple: stays 'invite' unless the hub allows public
+ // groups, since the join-policy section is not rendered otherwise.
const [joinPolicy, setJoinPolicy] = useState('invite');
const [roots, setRoots] = useState([]);
const [uploadIdx, setUploadIdx] = useState(0);
@@ -1133,29 +1142,31 @@ function CreateGroupWizard({ token, username, onCreated }) {
</div>
</div>
- <div class="settings-section">
- <h3 class="settings-heading">${t('create_group.join_policy')}</h3>
- <div class="choice-list">
- <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}">
- <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'}
- onChange=${() => setJoinPolicy('invite')} />
- <${Icon} name="lock" cls="choice-icon" />
- <span class="choice-text">
- <span class="choice-title">${t('create_group.invite')}</span>
- <span class="choice-desc">${t('create_group.invite_desc')}</span>
- </span>
- </label>
- <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}">
- <input type="radio" name="join_policy" checked=${joinPolicy === 'open'}
- onChange=${() => setJoinPolicy('open')} />
- <${Icon} name="globe" cls="choice-icon" />
- <span class="choice-text">
- <span class="choice-title">${t('create_group.open')}</span>
- <span class="choice-desc">${t('create_group.open_desc')}</span>
- </span>
- </label>
+ ${allowPublicGroups && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('create_group.join_policy')}</h3>
+ <div class="choice-list">
+ <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}">
+ <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'}
+ onChange=${() => setJoinPolicy('invite')} />
+ <${Icon} name="lock" cls="choice-icon" />
+ <span class="choice-text">
+ <span class="choice-title">${t('create_group.invite')}</span>
+ <span class="choice-desc">${t('create_group.invite_desc')}</span>
+ </span>
+ </label>
+ <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}">
+ <input type="radio" name="join_policy" checked=${joinPolicy === 'open'}
+ onChange=${() => setJoinPolicy('open')} />
+ <${Icon} name="globe" cls="choice-icon" />
+ <span class="choice-text">
+ <span class="choice-title">${t('create_group.open')}</span>
+ <span class="choice-desc">${t('create_group.open_desc')}</span>
+ </span>
+ </label>
+ </div>
</div>
- </div>
+ `}
<div class="settings-section">
<h3 class="settings-heading">${t('members.apps_title')}</h3>
@@ -1895,9 +1906,11 @@ function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) {
// ── Admin Panel ─────────────────────────────────────────────────────────────
-function AdminPage({ token }) {
- const [tab, setTab] = useState('stats');
+function AdminPage({ token, role }) {
+ const [tab, setTab] = useState('general');
const [stats, setStats] = useState(null);
+ const [settings, setSettings] = useState(null);
+ const [settingsSaving, setSettingsSaving] = useState(false);
const [users, setUsers] = useState([]);
const [usersTotal, setUsersTotal] = useState(0);
const [userSearch, setUserSearch] = useState('');
@@ -1920,6 +1933,26 @@ function AdminPage({ token }) {
} catch (e) { setError(e.message); }
}, [token]);
+ const loadSettings = useCallback(async () => {
+ try {
+ const data = await hubFetch('/v1/admin/settings', { token });
+ setSettings(data);
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const saveSettings = useCallback(async (patch) => {
+ setSettingsSaving(true);
+ setError('');
+ try {
+ // The response is the authoritative state — render that, not the
+ // optimistic value, so a rejected change never looks applied.
+ const data = await hubFetch('/v1/admin/settings',
+ { method: 'PATCH', body: patch, token });
+ setSettings(data);
+ } catch (e) { setError(e.message); }
+ finally { setSettingsSaving(false); }
+ }, [token]);
+
const loadUsers = useCallback(async (q = '') => {
try {
const data = await hubFetch(`/v1/admin/users?q=${encodeURIComponent(q)}&limit=100`, { token });
@@ -1954,7 +1987,8 @@ function AdminPage({ token }) {
useEffect(() => {
setError('');
- if (tab === 'stats') loadStats();
+ if (tab === 'general') loadSettings();
+ else if (tab === 'stats') loadStats();
else if (tab === 'users') loadUsers(userSearch);
else if (tab === 'groups') loadGroups();
else if (tab === 'nodes') {
@@ -1992,6 +2026,18 @@ function AdminPage({ token }) {
} catch (e) { setError(e.message); }
}, [token]);
+ const revokeGroup = useCallback(async (g) => {
+ // Suspending is the reversible tool and stays one click away; revoking
+ // pushes a signed revocation to every node hosting the group and there is
+ // no undo from here, so it names the group and asks first.
+ if (!confirm(t('admin.revoke_group_confirm', { group: g.name }))) return;
+ try {
+ await hubFetch('/v1/admin/revoke',
+ { method: 'POST', body: { target: 'group', target_id: g.id }, token });
+ loadGroups();
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
const showUserDetail = useCallback(async (userId) => {
try {
const data = await hubFetch(`/v1/admin/users/${userId}`, { token });
@@ -2013,7 +2059,8 @@ function AdminPage({ token }) {
} catch (e) { setError(e.message); }
}, [token]);
- const TABS = ['stats', 'users', 'groups', 'nodes', 'logs', 'blocklist'];
+ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist'];
+ const canEditSettings = role === 'admin';
return html`
<div>
@@ -2027,6 +2074,23 @@ function AdminPage({ token }) {
`)}
</div>
+ ${tab === 'general' && settings && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('admin.general_groups_heading')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('admin.allow_public_groups_label')}</span>
+ <label class="settings-value" style="cursor:pointer">
+ <input type="checkbox" checked=${settings.allow_public_groups}
+ disabled=${!canEditSettings || settingsSaving}
+ onChange=${e => saveSettings({ allow_public_groups: e.target.checked })} />
+ </label>
+ </div>
+ <p class="settings-hint">${t('admin.allow_public_groups_hint')}</p>
+ ${!canEditSettings && html`
+ <p class="settings-hint">${t('admin.settings_readonly')}</p>`}
+ </div>
+ `}
+
${tab === 'stats' && stats && html`
<div class="admin-stats">
${[['users', 'stat_users'], ['groups', 'stat_groups'],
@@ -2116,6 +2180,10 @@ function AdminPage({ token }) {
? html`<button class="admin-btn" onClick=${() => patchGroup(g.id, { status: 'active' })}>${t('admin.btn_unsuspend')}</button>`
: null
}
+ ${g.status !== 'revoked' && html`
+ <button class="admin-btn danger"
+ onClick=${() => revokeGroup(g)}>${t('admin.btn_revoke')}</button>
+ `}
</td>
</tr>
`)}
@@ -2917,6 +2985,11 @@ function App() {
const [notifDisabled, setNotifDisabled] = useState(false);
const [userPrefs, setUserPrefs] = useState({});
const [hasNodeKey, setHasNodeKey] = useState(false);
+ // Instance policy, fetched once, unauthenticated. `null` until it answers;
+ // treat unknown as "allowed" so a slow hub never blocks a legitimate private
+ // group — the hub refuses a public one server-side regardless.
+ const [hubInfo, setHubInfo] = useState(null);
+ const allowPublicGroups = !hubInfo || hubInfo.allow_public_groups !== false;
const resolved = resolveTheme(theme);
@@ -2952,6 +3025,10 @@ function App() {
localStorage.setItem(THEME_KEY, theme);
}, [theme, resolved]);
+ useEffect(() => {
+ hubFetch('/v1/hub/info').then(setHubInfo).catch(() => {});
+ }, []);
+
const fetchNotifications = useCallback(() => {
if (!user || notifDisabled) {
setNotifications([]); setUnreadCount(0); return;
@@ -3196,6 +3273,7 @@ function App() {
myGroupIds=${groups.map(g => g.id)} />`;
} else if (route === '/create-group') {
page = html`<${CreateGroupPage} token=${user.token} username=${user.username}
+ allowPublicGroups=${allowPublicGroups}
onCreated=${() => {
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
@@ -3216,7 +3294,7 @@ function App() {
onLeft=${handleLeftGroup} />`;
} else if (route === '/admin') {
page = (user.role === 'moderator' || user.role === 'admin')
- ? html`<${AdminPage} token=${user.token} />`
+ ? html`<${AdminPage} token=${user.token} role=${user.role} />`
: html`<${HomePage} groups=${groups} notifications=${notifications}
onMarkRead=${markRead} onPurge=${purgeNotifications} />`;
} else if (route === '/settings') {
@@ -3254,6 +3332,7 @@ function App() {
route=${route}
menuOpen=${menuOpen}
role=${user.role}
+ allowPublicGroups=${allowPublicGroups}
hasNodeKey=${hasNodeKey} />`}
${menuOpen && html`<div class="overlay visible"
onClick=${() => setMenuOpen(false)} />`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index d30fb93..dae16a1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -352,6 +352,11 @@ export default {
+ 'Node sich selbst hält; er hat sie von einem STUN-Server erfahren und uns '
+ 'geschickt. Sie hilft, den Node zu erreichen, und beweist nichts.',
'admin.tab_blocklist': 'Sperrliste',
+ 'admin.tab_general': "General",
+ 'admin.general_groups_heading': "Groups",
+ 'admin.allow_public_groups_label': "Allow members to create public groups",
+ 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.",
+ 'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
'admin.stat_users': 'Benutzer',
@@ -371,6 +376,8 @@ export default {
'admin.no_users': 'Keine Benutzer gefunden',
'admin.btn_suspend': 'Sperren',
'admin.btn_unsuspend': 'Entsperren',
+ 'admin.btn_revoke': "Revoke",
+ 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.",
'admin.btn_details': 'Details',
'admin.btn_delete': 'Löschen',
'admin.delete_confirm': 'Das Konto „{user}“ löschen? Das lässt sich nicht '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index e9ef2cc..6f12810 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -342,6 +342,11 @@ export default {
+ 'learned from a STUN server and sent to us; it is useful for reaching the '
+ 'node and is not evidence of anything.',
'admin.tab_blocklist': 'Blocklist',
+ 'admin.tab_general': "General",
+ 'admin.general_groups_heading': "Groups",
+ 'admin.allow_public_groups_label': "Allow members to create public groups",
+ 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.",
+ 'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
'admin.stat_users': 'Users',
@@ -361,6 +366,8 @@ export default {
'admin.no_users': 'No users found',
'admin.btn_suspend': 'Suspend',
'admin.btn_unsuspend': 'Unsuspend',
+ 'admin.btn_revoke': "Revoke",
+ 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.",
'admin.btn_details': 'Details',
'admin.btn_delete': 'Delete',
'admin.delete_confirm': 'Delete the account "{user}"? This cannot be undone. '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 6a92e01..0e51ede 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -348,6 +348,11 @@ export default {
+ 'es la suya, aprendida de un servidor STUN y enviada aquí; sirve para alcanzar el '
+ 'node y no prueba nada.',
'admin.tab_blocklist': 'Lista de bloqueo',
+ 'admin.tab_general': "General",
+ 'admin.general_groups_heading': "Groups",
+ 'admin.allow_public_groups_label': "Allow members to create public groups",
+ 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.",
+ 'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
'admin.stat_users': 'Usuarios',
@@ -367,6 +372,8 @@ export default {
'admin.no_users': 'No se ha encontrado ningún usuario',
'admin.btn_suspend': 'Suspender',
'admin.btn_unsuspend': 'Reactivar',
+ 'admin.btn_revoke': "Revoke",
+ 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.",
'admin.btn_details': 'Detalles',
'admin.btn_delete': 'Eliminar',
'admin.delete_confirm': '¿Eliminar la cuenta «{user}»? Esta acción no se puede '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 48b10fb..ccfc709 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -351,6 +351,11 @@ export default {
+ 'celle que le node croit être la sienne, apprise d’un serveur STUN puis '
+ 'transmise ; elle sert à joindre le node et ne prouve rien.',
'admin.tab_blocklist': 'Liste de blocage',
+ 'admin.tab_general': "Général",
+ 'admin.general_groups_heading': "Groupes",
+ 'admin.allow_public_groups_label': "Autoriser les membres à créer des groupes publics",
+ 'admin.allow_public_groups_hint': "Désactivé, les nouveaux groupes ne peuvent être que privés et sur invitation. Les groupes publics existants ne sont pas affectés — suspendez-les un par un depuis l'onglet Groupes.",
+ 'admin.settings_readonly': "Seul un administrateur peut modifier ces paramètres.",
// Admin stats
'admin.stat_users': 'Utilisateurs',
@@ -370,6 +375,8 @@ export default {
'admin.no_users': 'Aucun utilisateur trouvé',
'admin.btn_suspend': 'Suspendre',
'admin.btn_unsuspend': 'Réactiver',
+ 'admin.btn_revoke': "Révoquer",
+ 'admin.revoke_group_confirm': "Révoquer le groupe « {group} » ? Une révocation signée est envoyée à chaque node qui l'héberge, et c'est irréversible depuis cette page. Suspends-le plutôt si tu veux seulement le mettre en pause.",
'admin.btn_details': 'Détails',
'admin.btn_delete': 'Supprimer',
'admin.delete_confirm': 'Supprimer le compte « {user} » ? Cette action est '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 50aee7c..1220090 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -351,6 +351,11 @@ export default {
+ 'che il node crede essere il proprio, appreso da un server STUN e inviato a noi; '
+ 'serve a raggiungere il node e non prova nulla.',
'admin.tab_blocklist': 'Elenco dei blocchi',
+ 'admin.tab_general': "General",
+ 'admin.general_groups_heading': "Groups",
+ 'admin.allow_public_groups_label': "Allow members to create public groups",
+ 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.",
+ 'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
'admin.stat_users': 'Utenti',
@@ -370,6 +375,8 @@ export default {
'admin.no_users': 'Nessun utente trovato',
'admin.btn_suspend': 'Sospendi',
'admin.btn_unsuspend': 'Riattiva',
+ 'admin.btn_revoke': "Revoke",
+ 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.",
'admin.btn_details': 'Dettagli',
'admin.btn_delete': 'Elimina',
'admin.delete_confirm': 'Eliminare l’account «{user}»? L’operazione non può essere '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index f0b7e4b..f80f5d7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -344,6 +344,11 @@ export default {
+ '申告されたアドレスは、node が STUN サーバーから知り得た「自分のアドレス」として'
+ '送ってきたものです。node に到達するには役立ちますが、何かの証拠にはなりません。',
'admin.tab_blocklist': 'ブロックリスト',
+ 'admin.tab_general': "General",
+ 'admin.general_groups_heading': "Groups",
+ 'admin.allow_public_groups_label': "Allow members to create public groups",
+ 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.",
+ 'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
'admin.stat_users': 'ユーザー',
@@ -363,6 +368,8 @@ export default {
'admin.no_users': 'ユーザーが見つかりません',
'admin.btn_suspend': '停止',
'admin.btn_unsuspend': '再開',
+ 'admin.btn_revoke': "Revoke",
+ 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.",
'admin.btn_details': '詳細',
'admin.btn_delete': '削除',
'admin.delete_confirm': 'アカウント「{user}」を削除しますか?この操作は取り消せません。'
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 8f3178a..ddd62c2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -352,6 +352,11 @@ export default {
+ 'node zelf denkt te zijn, vernomen van een STUN-server en naar ons gestuurd; het '
+ 'helpt de node te bereiken en bewijst niets.',
'admin.tab_blocklist': 'Blokkeerlijst',
+ 'admin.tab_general': "General",
+ 'admin.general_groups_heading': "Groups",
+ 'admin.allow_public_groups_label': "Allow members to create public groups",
+ 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.",
+ 'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
'admin.stat_users': 'Gebruikers',
@@ -371,6 +376,8 @@ export default {
'admin.no_users': 'Geen gebruikers gevonden',
'admin.btn_suspend': 'Schorsen',
'admin.btn_unsuspend': 'Herstellen',
+ 'admin.btn_revoke': "Revoke",
+ 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.",
'admin.btn_details': 'Details',
'admin.btn_delete': 'Verwijderen',
'admin.delete_confirm': 'Het account "{user}" verwijderen? Dit kan niet ongedaan '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 73db56f..c4a3c32 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -364,6 +364,11 @@ export default {
+ 'poznany od serwera STUN i przesłany do nas; przydaje się do nawiązania '
+ 'połączenia i niczego nie dowodzi.',
'admin.tab_blocklist': 'Lista blokad',
+ 'admin.tab_general': "General",
+ 'admin.general_groups_heading': "Groups",
+ 'admin.allow_public_groups_label': "Allow members to create public groups",
+ 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.",
+ 'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
'admin.stat_users': 'Użytkownicy',
@@ -383,6 +388,8 @@ export default {
'admin.no_users': 'Nie znaleziono użytkowników',
'admin.btn_suspend': 'Zawieś',
'admin.btn_unsuspend': 'Przywróć',
+ 'admin.btn_revoke': "Revoke",
+ 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.",
'admin.btn_details': 'Szczegóły',
'admin.btn_delete': 'Usuń',
'admin.delete_confirm': 'Usunąć konto „{user}”? Tej operacji nie da się cofnąć. '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 1fc70dd..868ad30 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -350,6 +350,11 @@ export default {
+ 'aprendido de um servidor STUN e enviado a nós; serve para alcançar o node e não '
+ 'prova coisa alguma.',
'admin.tab_blocklist': 'Lista de bloqueio',
+ 'admin.tab_general': "General",
+ 'admin.general_groups_heading': "Groups",
+ 'admin.allow_public_groups_label': "Allow members to create public groups",
+ 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.",
+ 'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
'admin.stat_users': 'Usuários',
@@ -369,6 +374,8 @@ export default {
'admin.no_users': 'Nenhum usuário encontrado',
'admin.btn_suspend': 'Suspender',
'admin.btn_unsuspend': 'Reativar',
+ 'admin.btn_revoke': "Revoke",
+ 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.",
'admin.btn_details': 'Detalhes',
'admin.btn_delete': 'Excluir',
'admin.delete_confirm': 'Excluir a conta "{user}"? Esta ação não pode ser desfeita. '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index e67eed8..f019130 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -336,6 +336,11 @@ export default {
+ '需要答复查询时应以此为准。自报地址是 node 自认为的地址,从 STUN 服务器获知后发给我们;'
+ '它有助于连上该 node,但不能证明任何事情。',
'admin.tab_blocklist': '屏蔽列表',
+ 'admin.tab_general': "General",
+ 'admin.general_groups_heading': "Groups",
+ 'admin.allow_public_groups_label': "Allow members to create public groups",
+ 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.",
+ 'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
'admin.stat_users': '用户',
@@ -355,6 +360,8 @@ export default {
'admin.no_users': '未找到用户',
'admin.btn_suspend': '停用',
'admin.btn_unsuspend': '恢复',
+ 'admin.btn_revoke': "Revoke",
+ 'admin.revoke_group_confirm': "Revoke the group \"{group}\"? This pushes a signed revocation to every node hosting it and cannot be undone from here. Suspend it instead if you only need to pause it.",
'admin.btn_details': '详情',
'admin.btn_delete': '删除',
'admin.delete_confirm': '删除账户“{user}”?此操作无法撤销。'
diff --git a/packages/meshbay-hub/tests/test_public_groups_toggle.py b/packages/meshbay-hub/tests/test_public_groups_toggle.py
new file mode 100644
index 0000000..0d36b99
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_public_groups_toggle.py
@@ -0,0 +1,269 @@
+"""
+An admin can turn off public groups for the whole hub.
+
+The control is server-side and covers every hub-mediated path, not just
+creation:
+
+ * `create_group` refuses `visibility=public`
+ * `list_public_groups` (the directory) returns nothing
+ * `join_group` refuses open joining of a public group
+ * `group_online_nodes` hands a non-member no node to connect to
+ * `signaling.webrtc_offer` drops the "node hosts an open group" fallback
+ * `federation.export_directory` advertises nothing to peers
+
+Existing members of a group that predates the switch keep their membership row
+and their access — plan A, not a purge.
+"""
+
+import base64
+import hashlib
+from datetime import datetime, timezone
+
+import pytest
+from meshbay_hub.api.deps import set_admin_usernames
+from meshbay_hub.db.models import Group
+
+
+def _auth_key(password: str, username: str) -> str:
+ salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest()
+ return base64.b64encode(
+ hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode()
+
+
+async def _user(client, username, password="a-long-enough-passphrase"):
+ await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@example.com",
+ "auth_key": _auth_key(password, username)})
+ r = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": _auth_key(password, username)})
+ return {"Authorization": f"Bearer {r.json()['access_token']}"}
+
+
+async def _admin(client, username="root"):
+ await _user(client, username)
+ set_admin_usernames([username])
+ # re-login so the token is minted with the admin role in context
+ r = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": _auth_key("a-long-enough-passphrase", username)})
+ return {"Authorization": f"Bearer {r.json()['access_token']}"}
+
+
+def _public(name):
+ return {"name": name, "visibility": "public", "join_policy": "open"}
+
+
+async def _create_public(client, owner, name):
+ r = await client.post("/v1/groups", json=_public(name), headers=owner)
+ assert r.status_code == 201, r.text
+ return r.json()["group_id"]
+
+
+async def _mark_hosted(db_session, *group_ids):
+ """Pretend a node announced these groups, as /v1/nodes/ws would."""
+ for gid in group_ids:
+ (await db_session.get(Group, gid)).hosted_at = datetime.now(timezone.utc)
+ await db_session.commit()
+
+
+async def _set_public_groups(client, admin, allowed):
+ r = await client.patch("/v1/admin/settings",
+ json={"allow_public_groups": allowed}, headers=admin)
+ assert r.status_code == 200
+ assert r.json()["allow_public_groups"] is allowed
+
+
+@pytest.mark.asyncio
+async def test_public_groups_are_allowed_by_default(client):
+ owner = await _user(client, "alice")
+ r = await client.get("/v1/hub/info")
+ assert r.json()["allow_public_groups"] is True
+ r = await client.post("/v1/groups", json=_public("open-house"), headers=owner)
+ assert r.status_code == 201, r.text
+
+
+@pytest.mark.asyncio
+async def test_a_normal_member_cannot_change_the_setting(client):
+ member = await _user(client, "mallory")
+ r = await client.patch("/v1/admin/settings",
+ json={"allow_public_groups": False}, headers=member)
+ assert r.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_admin_disables_public_groups_end_to_end(client):
+ admin = await _admin(client)
+ owner = await _user(client, "bob")
+
+ r = await client.patch("/v1/admin/settings",
+ json={"allow_public_groups": False}, headers=admin)
+ assert r.status_code == 200
+ assert r.json()["allow_public_groups"] is False
+
+ # Reflected on both the admin read and the unauthenticated hub info.
+ assert (await client.get("/v1/admin/settings", headers=admin)
+ ).json()["allow_public_groups"] is False
+ assert (await client.get("/v1/hub/info")).json()["allow_public_groups"] is False
+
+ # A member is refused a public group...
+ r = await client.post("/v1/groups", json=_public("nope"), headers=owner)
+ assert r.status_code == 403
+ assert "public" in r.json()["detail"].lower()
+
+ # ...and so is the admin: the way back is to re-enable it, not slip past.
+ r = await client.post("/v1/groups", json=_public("admin-nope"), headers=admin)
+ assert r.status_code == 403
+
+ # Private groups are unaffected.
+ r = await client.post("/v1/groups",
+ json={"name": "still-fine", "visibility": "private"},
+ headers=owner)
+ assert r.status_code == 201, r.text
+
+
+@pytest.mark.asyncio
+async def test_re_enabling_restores_public_creation(client):
+ admin = await _admin(client, "chief")
+ owner = await _user(client, "carol")
+
+ await client.patch("/v1/admin/settings",
+ json={"allow_public_groups": False}, headers=admin)
+ r = await client.post("/v1/groups", json=_public("first-try"), headers=owner)
+ assert r.status_code == 403
+
+ await client.patch("/v1/admin/settings",
+ json={"allow_public_groups": True}, headers=admin)
+ r = await client.post("/v1/groups", json=_public("second-try"), headers=owner)
+ assert r.status_code == 201, r.text
+
+
+@pytest.mark.asyncio
+async def test_a_patch_without_the_field_is_a_no_op(client):
+ admin = await _admin(client, "keeper")
+ await client.patch("/v1/admin/settings",
+ json={"allow_public_groups": False}, headers=admin)
+ r = await client.patch("/v1/admin/settings", json={}, headers=admin)
+ assert r.status_code == 200
+ assert r.json()["allow_public_groups"] is False
+
+
+# ── plan A: existing public groups when the switch is off ────────────────────
+
+@pytest.mark.asyncio
+async def test_disabled_empties_the_public_directory(client, db_session):
+ admin = await _admin(client)
+ owner = await _user(client, "dora")
+ gid = await _create_public(client, owner, "town-square")
+ await _mark_hosted(db_session, gid)
+
+ listed = await client.get("/v1/groups")
+ assert any(g["id"] == gid for g in listed.json()["groups"])
+
+ await _set_public_groups(client, admin, False)
+
+ listed = await client.get("/v1/groups")
+ body = listed.json()
+ assert body["groups"] == [] and body["total"] == 0
+
+
+@pytest.mark.asyncio
+async def test_disabled_refuses_open_join_of_a_public_group(client, db_session):
+ admin = await _admin(client, "chief")
+ owner = await _user(client, "erin")
+ early = await _user(client, "early-bird")
+ late = await _user(client, "late-comer")
+ gid = await _create_public(client, owner, "commons")
+ await _mark_hosted(db_session, gid)
+
+ assert (await client.post(f"/v1/groups/{gid}/join", headers=early)).status_code == 200
+
+ await _set_public_groups(client, admin, False)
+
+ r = await client.post(f"/v1/groups/{gid}/join", headers=late)
+ assert r.status_code == 403
+
+ # The person who joined while it was allowed is still a member.
+ mine = await client.get("/v1/groups/mine", headers=early)
+ assert any(g["id"] == gid for g in mine.json()["groups"])
+
+
+@pytest.mark.asyncio
+async def test_disabled_hands_a_non_member_no_node(client, db_session):
+ admin = await _admin(client, "chief")
+ owner = await _user(client, "frank")
+ member = await _user(client, "grace")
+ stranger = await _user(client, "heidi")
+ gid = await _create_public(client, owner, "atrium")
+ await _mark_hosted(db_session, gid)
+ assert (await client.post(f"/v1/groups/{gid}/join", headers=member)).status_code == 200
+
+ # While allowed, anyone may ask which nodes serve a public group.
+ assert (await client.get(f"/v1/groups/{gid}/nodes", headers=stranger)
+ ).status_code == 200
+
+ await _set_public_groups(client, admin, False)
+
+ assert (await client.get(f"/v1/groups/{gid}/nodes", headers=stranger)
+ ).status_code == 403
+ # Members and the owner still get an answer (no nodes online here, but 200).
+ assert (await client.get(f"/v1/groups/{gid}/nodes", headers=member)
+ ).status_code == 200
+ assert (await client.get(f"/v1/groups/{gid}/nodes", headers=owner)
+ ).status_code == 200
+
+
+def _mhp_token(hub_id):
+ """A peer-hub JWT, signed with the running hub's own key.
+
+ `federation._issue_mhp_token` binds `_hub_sk_pem` at import time, before the
+ lifespan loads it, so it cannot be used from a test. This signs directly.
+ """
+ import time
+ import uuid
+
+ import jwt
+ from meshbay_hub import auth as hub_auth
+
+ now = int(time.time())
+ # No `aud`: export_directory verifies without an expected audience, and PyJWT
+ # rejects a token that carries `aud` when decode() is given none.
+ return jwt.encode(
+ {"iss": hub_id, "sub": hub_id,
+ "jti": str(uuid.uuid4()), "iat": now, "exp": now + 300},
+ hub_auth._hub_sk_pem, algorithm="EdDSA")
+
+
+@pytest.mark.asyncio
+async def test_disabled_empties_the_federation_export(client):
+ admin = await _admin(client)
+ owner = await _user(client, "ivan")
+ await _create_public(client, owner, "exported-square")
+
+ info = (await client.get("/mhp/info")).json()
+ await client.post("/mhp/peers", headers=admin, json={
+ "hub_id": info["hub_id"], "hub_url": "https://peer.example",
+ "pk_hub_pem": info["pk_hub_pem"],
+ })
+ tok = _mhp_token(info["hub_id"])
+
+ r = await client.get("/mhp/directory", headers={"Authorization": f"Bearer {tok}"})
+ assert r.status_code == 200 and len(r.json()["groups"]) == 1
+
+ await _set_public_groups(client, admin, False)
+
+ r = await client.get("/mhp/directory", headers={"Authorization": f"Bearer {tok}"})
+ assert r.status_code == 200 and r.json()["groups"] == []
+
+
+@pytest.mark.asyncio
+async def test_re_enabling_brings_the_directory_back(client, db_session):
+ admin = await _admin(client, "chief")
+ owner = await _user(client, "judy")
+ gid = await _create_public(client, owner, "reopened")
+ await _mark_hosted(db_session, gid)
+
+ await _set_public_groups(client, admin, False)
+ assert (await client.get("/v1/groups")).json()["groups"] == []
+
+ await _set_public_groups(client, admin, True)
+ listed = await client.get("/v1/groups")
+ assert any(g["id"] == gid for g in listed.json()["groups"])
diff --git a/packages/meshbay-hub/tests/test_revocation.py b/packages/meshbay-hub/tests/test_revocation.py
index 494d77d..d1147e1 100644
--- a/packages/meshbay-hub/tests/test_revocation.py
+++ b/packages/meshbay-hub/tests/test_revocation.py
@@ -121,3 +121,37 @@ async def test_revoke_group(client):
}, headers=hdrs)
assert r.status_code == 200
assert r.json()["status"] == "revoked"
+
+
+@pytest.mark.asyncio
+async def test_group_status_message_names_the_real_state(client):
+ """A member of a revoked group must not be told it was merely 'suspended'.
+
+ `GET /v1/groups/{id}/nodes` and `POST /join` used to answer "Group is
+ suspended" for any non-active status. Suspend is the reversible hub flag;
+ revoke is a signed instruction every node enforces. The client shows this
+ string verbatim, so it has to be the truth.
+ """
+ sk_ed = Ed25519PrivateKey.generate()
+ sk_x = X25519PrivateKey.generate()
+ admin_token, _ = await _register_and_login(
+ client, "admin_msg",
+ pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key()))
+ set_admin_usernames(["admin_msg"])
+ hdrs = {"Authorization": f"Bearer {admin_token}"}
+
+ gid = (await client.post("/v1/groups", json={"name": "state-msg"},
+ headers=hdrs)).json()["group_id"]
+
+ # Suspend → the message says suspended.
+ await client.patch(f"/v1/admin/groups/{gid}", json={"status": "suspended"},
+ headers=hdrs)
+ r = await client.get(f"/v1/groups/{gid}/nodes", headers=hdrs)
+ assert r.status_code == 403 and r.json()["detail"] == "Group is suspended"
+
+ # Revoke → the message says revoked, not suspended.
+ await client.post("/v1/admin/revoke",
+ json={"target": "group", "target_id": gid, "reason": "x"},
+ headers=hdrs)
+ r = await client.get(f"/v1/groups/{gid}/nodes", headers=hdrs)
+ assert r.status_code == 403 and r.json()["detail"] == "Group is revoked"