diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-28 02:51:00 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-28 02:51:00 +0200 |
| commit | b5b4f188a39fc96c4d32e67151e067b1add6dcfc (patch) | |
| tree | ce0c4ff78044a56c0882d7ba94fbea436f0e83c3 | |
| parent | e1f1b65cfac031096e4bae24ccf102ca0dbb86d9 (diff) | |
| download | meshbay-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
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" |