summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-20 22:21:24 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-20 22:21:24 +0200
commitae52b69b14a6997d01aad66f49fbea7b5ca2dfe6 (patch)
tree26f850a88565846a139868a4b85c715734751a41
parentc8af746c846b5dbc792f7e4f0d806647d513cc5c (diff)
parent2e9490ca27047ae03e495d397abbe1aec1b2273a (diff)
downloadmeshbay-ae52b69b14a6997d01aad66f49fbea7b5ca2dfe6.tar.gz
Merge feat/unified-group-management: wizard, public groups, activity sidebar
-rw-r--r--packages/meshbay-client/src/main.js95
-rw-r--r--packages/meshbay-client/src/preload.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/signaling.py23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7b8c9d0e1f2_add_group_last_activity.py28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js501
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css68
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py32
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py19
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py94
-rw-r--r--packages/meshbay-node/src/meshbay_node/roots.py70
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py159
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py38
-rw-r--r--packages/meshbay-node/tests/test_ops.py46
-rw-r--r--packages/meshbay-node/tests/test_roots.py67
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py4
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py4
22 files changed, 1146 insertions, 215 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index e7e7b15..f2ec645 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -643,6 +643,101 @@ function registerBridge() {
try { fs.unlinkSync(sink.path); } catch { /* already gone */ }
return true;
});
+
+ // ── Node loopback bridge ─────────────────────────────────────────────────
+ //
+ // The renderer never sees the session token. It names an operation and this
+ // process executes it — the same pattern as hub:fetch. The token is read
+ // from the daemon's data directory, cached for the lifetime of this process,
+ // and never exposed through the preload.
+
+ let _nodeToken = null;
+ let _nodePort = 18000;
+ let _nodePairingCode = null;
+
+ function nodeConfigPath() {
+ return path.join(os.homedir(), '.config', 'meshbay', 'node.toml');
+ }
+
+ function readNodeConfig() {
+ try {
+ const text = fs.readFileSync(nodeConfigPath(), 'utf8');
+ let dataDir = path.join(os.homedir(), '.local', 'share', 'meshbay');
+ let uiPort = 18000;
+ const dataMatch = text.match(/^\s*data_dir\s*=\s*"([^"]+)"/m);
+ if (dataMatch) {
+ dataDir = dataMatch[1].replace(/^~/, os.homedir());
+ }
+ const portMatch = text.match(/^\s*ui_port\s*=\s*(\d+)/m);
+ if (portMatch) uiPort = parseInt(portMatch[1], 10);
+ return { dataDir, uiPort };
+ } catch {
+ return null;
+ }
+ }
+
+ function readNodeToken(dataDir) {
+ try {
+ return fs.readFileSync(path.join(dataDir, 'ui-token'), 'utf8').trim();
+ } catch {
+ return null;
+ }
+ }
+
+ ipcMain.handle('node:detect', async () => {
+ const nc = readNodeConfig();
+ if (!nc) return { detected: false };
+ const token = readNodeToken(nc.dataDir);
+ if (!token) return { detected: false };
+ _nodeToken = token;
+ _nodePort = nc.uiPort;
+ try {
+ const r = await fetch(
+ `http://127.0.0.1:${_nodePort}/api/status?t=${_nodeToken}`,
+ { signal: AbortSignal.timeout(3000) });
+ if (!r.ok) return { detected: false };
+ const status = await r.json();
+ return {
+ detected: true,
+ status: status.status,
+ pk_node_ed25519: status.pk_node_ed25519 || '',
+ };
+ } catch {
+ return { detected: false };
+ }
+ });
+
+ ipcMain.handle('node:call', async (_e, method, apiPath, body) => {
+ if (!_nodeToken) throw new Error('Node not detected');
+ const sep = apiPath.includes('?') ? '&' : '?';
+ const url = `http://127.0.0.1:${_nodePort}${apiPath}${sep}t=${_nodeToken}`;
+ const init = { method: String(method).toUpperCase() };
+ if (body !== undefined && body !== null) {
+ init.headers = { 'Content-Type': 'application/json' };
+ init.body = JSON.stringify(body);
+ }
+ init.signal = AbortSignal.timeout(30000);
+ const r = await fetch(url, init);
+ const text = await r.text();
+ let data;
+ try { data = JSON.parse(text); } catch { data = text; }
+ if (!r.ok) {
+ const msg = (data && data.error) || (data && data.detail) || text;
+ throw new Error(`Node ${r.status}: ${msg}`);
+ }
+ return data;
+ });
+
+ ipcMain.handle('node:pairing-code', async () => {
+ const code = _nodePairingCode;
+ _nodePairingCode = null;
+ return code;
+ });
+
+ ipcMain.handle('node:set-pairing-code', async (_e, code) => {
+ _nodePairingCode = code || null;
+ return true;
+ });
}
/**
diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js
index e3af68f..04dfe44 100644
--- a/packages/meshbay-client/src/preload.js
+++ b/packages/meshbay-client/src/preload.js
@@ -80,6 +80,16 @@ contextBridge.exposeInMainWorld('meshbay', {
choose: () => ipcRenderer.invoke('root:choose'),
},
+ // The local node, if one is running. The renderer never sees the session
+ // token — it names an operation and the main process executes it, the same
+ // pattern as hub:fetch.
+ node: {
+ detect: () => ipcRenderer.invoke('node:detect'),
+ call: (method, path, body) => ipcRenderer.invoke('node:call', method, path, body),
+ pairingCode: () => ipcRenderer.invoke('node:pairing-code'),
+ setPairingCode: (code) => ipcRenderer.invoke('node:set-pairing-code', code),
+ },
+
// A sink that writes to disk as chunks arrive, never a buffer handed over at
// the end. `auto` uses the remembered folder without a dialog, which is what
// "save automatically" means; without one, or when the person asked to be
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 6819ff6..65307f1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -2,7 +2,8 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
-from sqlalchemy import func, or_, select
+from datetime import datetime, timezone
+from sqlalchemy import func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.api.deps import get_current_user, require_user_scope
@@ -36,7 +37,7 @@ async def my_groups(
# added to it before a node exists would see a name they cannot
# open and cannot be told why.
or_(Group.hosted_at.is_not(None), Group.admin_id == current_user.id))
- .order_by(Group.name)
+ .order_by(Group.last_activity_at.desc())
)
groups = result.scalars().all()
muted_rows = await db.execute(
@@ -63,12 +64,35 @@ async def my_groups(
# which is the evidence that actually concerns the user.
"node_online": bool(get_online_nodes_for_group(g.id)),
"hosted": g.hosted_at is not None,
+ "last_activity_at": g.last_activity_at.isoformat(),
}
for g in groups
]
}
+@router.post("/{group_id}/activity")
+async def touch_group_activity(
+ group_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Bump a group's last_activity_at. Called by the client on chat/file events."""
+ result = await db.execute(
+ select(GroupMember.group_id).where(
+ GroupMember.group_id == group_id,
+ GroupMember.user_id == current_user.id,
+ ))
+ if not result.first():
+ raise HTTPException(status_code=403, detail="Not a member")
+ await db.execute(
+ update(Group)
+ .where(Group.id == group_id)
+ .values(last_activity_at=datetime.now(timezone.utc)))
+ await db.commit()
+ return {"ok": True}
+
+
@router.get("/{group_id}/nodes")
async def group_online_nodes(
group_id: str,
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index d555f9f..003e396 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -285,6 +285,11 @@ async def node_websocket(ws: WebSocket):
elif msg.get("type") == "webrtc_answer":
from meshbay_hub.api.signaling import handle_webrtc_answer
handle_webrtc_answer(msg)
+ elif msg.get("type") == "update_groups":
+ new_gids = msg.get("group_ids", [])
+ _node_groups[node_id] = new_gids
+ await _mark_hosted(new_gids)
+ log.info("Node %s updated groups: %d", node_id[:8], len(new_gids))
elif msg.get("type") == "chat_notify":
asyncio.ensure_future(_handle_chat_notify(
msg.get("group_id", ""),
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
index cb00a67..84c1167 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
@@ -90,7 +90,9 @@ async def webrtc_offer(
if not ws:
raise HTTPException(status_code=404, detail="Node not connected")
- # The caller must share at least one active group with the target node.
+ # 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).
node_group_ids = set(_node_groups.get(node_id, []))
if node_group_ids:
result = await db.execute(
@@ -100,12 +102,19 @@ async def webrtc_offer(
))
shared = [gid for (gid,) in result.all()]
if not shared:
- raise HTTPException(status_code=403, detail="Not a member of any group on this node")
-
- 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")
+ 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():
+ 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")
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/a7b8c9d0e1f2_add_group_last_activity.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7b8c9d0e1f2_add_group_last_activity.py
new file mode 100644
index 0000000..8a4e2ee
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7b8c9d0e1f2_add_group_last_activity.py
@@ -0,0 +1,28 @@
+"""add_group_last_activity_at
+
+Revision ID: a7b8c9d0e1f2
+Revises: f1a2b3c4d5e6
+Create Date: 2026-08-20 20:50:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'a7b8c9d0e1f2'
+down_revision: Union[str, Sequence[str], None] = 'f1a2b3c4d5e6'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column('groups',
+ sa.Column('last_activity_at', sa.DateTime(timezone=True),
+ server_default=sa.func.now(), nullable=False))
+ op.execute("UPDATE groups SET last_activity_at = created_at")
+
+
+def downgrade() -> None:
+ op.drop_column('groups', 'last_activity_at')
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index f1f11e2..3966bdc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -103,7 +103,9 @@ class Group(Base):
# group. Until then the group has no files, no key and nobody to serve it, so
# it is shown to its owner only and is what `prune-groups` collects. Set once
# and never cleared: a node going offline does not un-host a group.
- hosted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ hosted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ last_activity_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=_now, nullable=False)
members: Mapped[list["GroupMember"]] = relationship(back_populates="group")
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 3fdba07..50ee9f6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -638,21 +638,23 @@ function Sidebar({ groups, presence, route, menuOpen, role, hasNodeKey }) {
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>
- <a class="sidebar-item ${route === '/create-group' ? 'active' : ''}"
- href="#/create-group"><${Icon} name="plus" /> ${t('sidebar.create_group')}</a>
</div>
${platform.capabilities.nodeAdmin && hasNodeKey && html`
<div class="sidebar-section">
<div class="sidebar-heading">${t('sidebar.node')}</div>
<a class="sidebar-item ${route === '/node' ? 'active' : ''}"
href="#/node"><${Icon} name="server" /> ${t('node.title')}</a>
+ <a class="sidebar-item ${route === '/create-group' ? 'active' : ''}"
+ href="#/create-group"><${Icon} name="plus" /> ${t('sidebar.create_group')}</a>
</div>
`}
<div class="sidebar-section">
<div class="sidebar-heading">${t('sidebar.my_groups')}</div>
${groups.length === 0
? html`<div class="sidebar-empty">${t('sidebar.no_groups')}</div>`
- : groups.map(g => {
+ : [...groups].sort((a, b) =>
+ (b.last_activity_at || b.created_at || '').localeCompare(
+ a.last_activity_at || a.created_at || '')).map(g => {
// Three states, each backed by something. `node_online` comes from
// the hub's signaling registry and rides on the group list itself,
// so there is no poll and no timer; a connection this browser tried
@@ -984,7 +986,9 @@ function ExplorePage({ token, myGroupIds }) {
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<h2 style="margin:0">${t('explore.title')}</h2>
- <a class="admin-btn" href="#/create-group">${t('explore.create_group')}</a>
+ ${platform.node.available && html`
+ <a class="admin-btn" href="#/create-group">${t('explore.create_group')}</a>
+ `}
</div>
<div class="file-toolbar" style="margin-bottom:16px">
<input type="text" class="admin-search" placeholder="${t('explore.search')}"
@@ -1028,7 +1032,12 @@ function ExplorePage({ token, myGroupIds }) {
// ── Create Group Page ────────────────────────────────────────────────────────
-function CreateGroupPage({ token, onCreated }) {
+function CreateGroupPage(props) {
+ if (platform.node.available) return html`<${CreateGroupWizard} ...${props} />`;
+ return html`<${CreateGroupFormSimple} ...${props} />`;
+}
+
+function CreateGroupFormSimple({ token, onCreated }) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [joinPolicy, setJoinPolicy] = useState('invite');
@@ -1041,8 +1050,6 @@ function CreateGroupPage({ token, onCreated }) {
setLoading(true);
setError('');
try {
- // Derived, not asked: "open" is what makes a group listed, and there is
- // no third combination the server would accept.
const body = { name: name.trim(), join_policy: joinPolicy,
visibility: joinPolicy === 'open' ? 'public' : 'private' };
if (description.trim()) body.description = description.trim().slice(0, 512);
@@ -1083,15 +1090,6 @@ function CreateGroupPage({ token, onCreated }) {
</div>
</div>
- ${/* One question, not two. Visibility and admission were separate
- selectors that could only ever be set together: a public group
- admits everyone by definition, and a private one that anyone may
- join is a directory listing nobody can find. The server already
- refused public+invite with a 422 — the form could build a request
- that could not succeed. Now the answer to "who can join" settles
- both, and the descriptions say what each one means for who can
- *find* the group, which is the part the visibility box was there
- to state and no longer needs to. */ html`
<div class="settings-section">
<h3 class="settings-heading">${t('create_group.join_policy')}</h3>
<div class="choice-list">
@@ -1115,7 +1113,6 @@ function CreateGroupPage({ token, onCreated }) {
</label>
</div>
</div>
- `}
<button class="btn-primary" type="submit" disabled=${loading}>
${loading ? t('create_group.creating') : t('create_group.submit')}
@@ -1125,6 +1122,300 @@ function CreateGroupPage({ token, onCreated }) {
`;
}
+// ── Create Group Wizard (Electron-only) ─────────────────────────────────────
+
+function CreateGroupWizard({ token, onCreated }) {
+ const [step, setStep] = useState(0); // 0=node check, 1=details, 2=setup, 3=done
+ const [nodeStatus, setNodeStatus] = useState(null); // null=loading, object=result
+ const [error, setError] = useState('');
+
+ // Step 1 fields
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [joinPolicy, setJoinPolicy] = useState('invite');
+ const [roots, setRoots] = useState([]);
+ const [uploadIdx, setUploadIdx] = useState(0);
+
+ // Step 2 progress
+ const [setupSteps, setSetupSteps] = useState([]);
+ const [setupError, setSetupError] = useState('');
+ const [groupId, setGroupId] = useState('');
+
+ // Step 0: detect node
+ const detectNode = useCallback(async () => {
+ setNodeStatus(null);
+ setError('');
+ try {
+ const result = await platform.node.detect();
+ setNodeStatus(result);
+ if (result.detected) {
+ // Auto-link node key to hub if not already done
+ if (result.pk_node_ed25519) {
+ try {
+ await hubFetch('/v1/users/me/node_key', {
+ method: 'PUT', token,
+ body: { pk_node_ed25519: result.pk_node_ed25519 },
+ });
+ } catch { /* already linked or same key */ }
+ }
+ setStep(1);
+ }
+ } catch (err) {
+ setError(err.message);
+ setNodeStatus({ detected: false });
+ }
+ }, [token]);
+
+ useEffect(() => { detectNode(); }, [detectNode]);
+
+ const addRoot = useCallback(async () => {
+ const chosen = await platform.rootPicker.choose();
+ if (!chosen) return;
+ if (roots.some(r => r.path === chosen.path)) return;
+ setRoots(prev => [...prev, chosen]);
+ }, [roots]);
+
+ const removeRoot = useCallback((idx) => {
+ setRoots(prev => {
+ const next = prev.filter((_, i) => i !== idx);
+ if (uploadIdx >= next.length && next.length > 0) setUploadIdx(0);
+ return next;
+ });
+ }, [uploadIdx]);
+
+ const runSetup = useCallback(async () => {
+ setStep(2);
+ setSetupError('');
+ const steps = [
+ { label: t('wizard.step_create_hub'), status: 'pending' },
+ { label: t('wizard.step_attach'), status: 'pending' },
+ ];
+ if (roots.length > 1)
+ steps.push({ label: t('wizard.step_add_roots'), status: 'pending' });
+ steps.push({ label: t('wizard.step_gek'), status: 'pending' });
+ steps.push({ label: t('wizard.step_pair'), status: 'pending' });
+ setSetupSteps([...steps]);
+
+ let si = 0;
+ const update = (status) => {
+ steps[si].status = status;
+ setSetupSteps([...steps]);
+ };
+ const advance = () => { si++; };
+
+ try {
+ // 1. Create group on hub
+ update('running');
+ const body = { name: name.trim(), join_policy: joinPolicy,
+ visibility: joinPolicy === 'open' ? 'public' : 'private' };
+ if (description.trim()) body.description = description.trim().slice(0, 512);
+ const data = await hubFetch('/v1/groups', { method: 'POST', token, body });
+ const gid = data.group_id;
+ setGroupId(gid);
+ update('done');
+ advance();
+
+ // 2. Attach to node with first root
+ update('running');
+ const mainRoot = roots[uploadIdx] || roots[0];
+ const attachBody = { name: name.trim(), shared_dir: mainRoot.path };
+ if (roots.length === 1 || uploadIdx === 0) {
+ attachBody.upload_dir = mainRoot.path;
+ }
+ await platform.node.call('POST', '/api/groups/attach', attachBody);
+ await platform.node.call('POST', '/api/reload');
+ update('done');
+ advance();
+
+ // 3. Add extra roots (if >1)
+ if (roots.length > 1) {
+ update('running');
+ for (let i = 0; i < roots.length; i++) {
+ if (i === (uploadIdx < roots.length ? uploadIdx : 0)) continue;
+ const r = roots[i];
+ await platform.node.call('POST', `/api/groups/${gid}/roots`, {
+ path: r.path, name: r.name,
+ upload: i === uploadIdx,
+ });
+ }
+ update('done');
+ advance();
+ }
+
+ // 4. GEK init
+ update('running');
+ await platform.node.call('POST', `/api/groups/${gid}/gek`);
+ update('done');
+ advance();
+
+ // 5. Generate pairing code
+ update('running');
+ const pairResult = await platform.node.call('POST', '/api/operator/pair');
+ if (pairResult && pairResult.code) {
+ await platform.node.setPairingCode(pairResult.code);
+ }
+ update('done');
+
+ // Reload node config so it picks up the new group
+ try { await platform.node.call('POST', '/api/reload'); } catch { /* best effort */ }
+
+ setStep(3);
+ if (onCreated) onCreated();
+ } catch (err) {
+ update('error');
+ setSetupError(platform.bridgeMessage(err));
+ }
+ }, [name, description, joinPolicy, roots, uploadIdx, token, onCreated]);
+
+ // Step 0: Node detection
+ if (step === 0) {
+ if (nodeStatus === null) {
+ return html`<div class="page-content">
+ <h2>${t('wizard.title')}</h2>
+ <p class="page-message">${t('wizard.detecting')}</p>
+ </div>`;
+ }
+ if (!nodeStatus.detected) {
+ return html`<div class="page-content">
+ <h2>${t('wizard.title')}</h2>
+ <p class="page-message">${t('wizard.node_not_found')}</p>
+ ${error && html`<div class="error-msg">${error}</div>`}
+ <div style="display:flex;gap:8px;margin-top:16px">
+ <button class="btn btn-primary" onClick=${detectNode}>
+ ${t('wizard.retry')}</button>
+ </div>
+ </div>`;
+ }
+ }
+
+ // Step 1: Group details + directories
+ if (step === 1) {
+ const canProceed = name.trim() && roots.length > 0;
+ return html`<div class="page-content">
+ <h2>${t('wizard.title')}</h2>
+ ${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`}
+
+ <div class="settings-section">
+ <div class="form-field">
+ <label class="form-label">${t('create_group.name')}</label>
+ <input type="text" placeholder="${t('create_group.name_placeholder')}"
+ value=${name} onInput=${e => setName(e.target.value)} required autofocus />
+ </div>
+
+ <div class="form-field">
+ <label class="form-label">${t('create_group.description')}</label>
+ <textarea class="form-textarea" rows="3" maxlength="512"
+ placeholder="${t('create_group.description_hint')}"
+ value=${description}
+ onInput=${e => setDescription(e.target.value)} />
+ <div class="form-char-count">${description.length}/512</div>
+ </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>
+ </div>
+ </div>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('wizard.directories')}</h3>
+ <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">
+ ${t('wizard.directories_hint')}</p>
+ ${roots.map((r, i) => html`
+ <div class="wizard-root" key=${r.path}>
+ <div class="wizard-root-info">
+ <${Icon} name="folder" />
+ <span class="wizard-root-name">${r.name}</span>
+ <span class="wizard-root-path">${r.path}</span>
+ ${i === uploadIdx && html`
+ <span class="node-root-badge">${t('wizard.upload_target')}</span>`}
+ </div>
+ <div class="wizard-root-actions">
+ ${roots.length > 1 && i !== uploadIdx && html`
+ <button class="btn btn-small btn-secondary"
+ onClick=${() => setUploadIdx(i)}>
+ ${t('wizard.set_upload')}</button>`}
+ <button class="btn btn-small btn-danger"
+ onClick=${() => removeRoot(i)}>
+ ${t('wizard.remove')}</button>
+ </div>
+ </div>
+ `)}
+ <button class="btn btn-secondary" style="margin-top:8px"
+ onClick=${addRoot}>
+ <${Icon} name="folder-plus" /> ${t('wizard.add_directory')}
+ </button>
+ </div>
+
+ <div style="display:flex;gap:8px;margin-top:16px">
+ <button class="btn btn-primary" disabled=${!canProceed}
+ onClick=${runSetup}>
+ ${t('wizard.create_and_setup')}</button>
+ </div>
+ </div>`;
+ }
+
+ // Step 2: Automatic setup progress
+ if (step === 2) {
+ return html`<div class="page-content">
+ <h2>${t('wizard.title')}</h2>
+ <p class="page-message">${t('wizard.setting_up')}</p>
+ <div class="wizard-progress">
+ ${setupSteps.map((s, i) => html`
+ <div class="wizard-step wizard-step-${s.status}" key=${i}>
+ <span class="wizard-step-icon">
+ ${s.status === 'done' ? '✓' :
+ s.status === 'running' ? '●' :
+ s.status === 'error' ? '✗' : '○'}
+ </span>
+ <span>${s.label}</span>
+ </div>
+ `)}
+ </div>
+ ${setupError && html`
+ <div class="error-msg" style="margin-top:16px">${setupError}</div>
+ <div style="display:flex;gap:8px;margin-top:8px">
+ <button class="btn btn-primary" onClick=${runSetup}>
+ ${t('wizard.retry')}</button>
+ <button class="btn btn-secondary" onClick=${() => {
+ if (onCreated) onCreated();
+ navigate('/');
+ }}>
+ ${t('wizard.finish_later')}</button>
+ </div>
+ `}
+ </div>`;
+ }
+
+ // Step 3: Done
+ return html`<div class="page-content">
+ <h2>${t('wizard.done_title')}</h2>
+ <p class="page-message">${t('wizard.done_message')}</p>
+ <button class="btn btn-primary" style="margin-top:16px"
+ onClick=${() => navigate(`/groups/${groupId}`)}>
+ ${t('wizard.go_to_group')}</button>
+ </div>`;
+}
+
// ── Helpers ──────────────────────────────────────────────────────────────────
const FILE_ICONS = {
@@ -1322,8 +1613,19 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const [previewEntry, setPreviewEntry] = useState(null);
const defaultTab = (userPrefs && (userPrefs[`default_tab:${groupId}`] || userPrefs['default_tab'])) || 'chat';
const [tab, setTab] = useState(defaultTab);
+ useEffect(() => { setTab(defaultTab); }, [groupId]);
const [groupMuted, setGroupMuted] = useState(() => !!(group && group.muted));
+ const _lastTouch = useRef(0);
+ const touchActivity = useCallback(() => {
+ const now = Date.now();
+ if (now - _lastTouch.current < 60_000) return;
+ _lastTouch.current = now;
+ const ts = new Date().toISOString();
+ if (onGroupUpdated) onGroupUpdated(groupId, { last_activity_at: ts });
+ hubFetch(`/v1/groups/${groupId}/activity`, { method: 'POST', token }).catch(() => {});
+ }, [groupId, token, onGroupUpdated]);
+
const toggleGroupMute = useCallback(async () => {
const next = !groupMuted;
setGroupMuted(next);
@@ -1477,6 +1779,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
if (cancelled) return;
applyIndex(indexMsg);
setStatus('connected');
+ touchActivity();
// First-hand evidence, and the strongest available: this browser spoke
// to the node. It outranks whatever the hub said in the group list.
if (onPresence) onPresence(groupId, 'online');
@@ -2170,7 +2473,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
${tab === 'chat' && status === 'connected' && html`
<${ChatPanel} transportRef=${transportRef} username=${username}
entries=${entries} gekRef=${gekRef} onRefreshIndex=${refreshIndex}
- mayUpload=${mayUpload}
+ mayUpload=${mayUpload} onActivity=${touchActivity}
onPreview=${(entry) => {
if (entry.type === 'video') setVideoEntry(entry);
else setPreviewEntry(entry);
@@ -2194,7 +2497,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
onPaired=${() => setOperatorPaired(true)} />
`}
`}
- ${status === 'offline' && html`
+ ${status === 'offline' && !group && html`
<p class="page-message">
${t('group.offline_title')}
${' '}${t('group.offline_hint')}
@@ -2381,6 +2684,31 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
const [inviteUser, setInviteUser] = useState('');
const [inviting, setInviting] = useState(false);
const [error, setError] = useState('');
+
+ // Node loopback state (Electron-only)
+ const [nodeDetected, setNodeDetected] = useState(false);
+ const [nodeRoots, setNodeRoots] = useState([]);
+ const [nodeGroupName, setNodeGroupName] = useState('');
+ const [nodeBusy, setNodeBusy] = useState(false);
+ const [nodeMsg, setNodeMsg] = useState('');
+
+ const loadNodeInfo = useCallback(async () => {
+ if (!platform.node.available) return;
+ try {
+ const detect = await platform.node.detect();
+ if (!detect.detected) { setNodeDetected(false); return; }
+ setNodeDetected(true);
+ const data = await platform.node.call('GET', '/api/groups');
+ const groups = data.groups || [];
+ const ng = groups.find(g => g.id === groupId);
+ if (ng) {
+ setNodeRoots(ng.roots || []);
+ setNodeGroupName(ng.name || '');
+ }
+ } catch { setNodeDetected(false); }
+ }, [groupId]);
+
+ useEffect(() => { loadNodeInfo(); }, [loadNodeInfo]);
const [inviteCode, setInviteCode] = useState(null);
const [pairCode, setPairCode] = useState('');
const [pairStatus, setPairStatus] = useState('');
@@ -2498,7 +2826,16 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
setError('');
setRemoving(member.user_id);
try {
- if (transport && transport.connected && operatorPaired) {
+ if (platform.node.available) {
+ try {
+ await platform.node.call('POST',
+ `/api/members/${member.user_id}/revoke?group_id=${groupId}`);
+ } catch { /* best effort — node may not host this group */ }
+ try {
+ await platform.node.call('POST',
+ `/api/members/${member.user_id}/unpin`);
+ } catch { /* best effort */ }
+ } else if (transport && transport.connected && operatorPaired) {
const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
const signFn = (sk && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
@@ -2585,7 +2922,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
${/* Inviting needs the node: it is the node that wraps the group key and
- issues the code, not the hub. */ isAdmin && html`
+ issues the code, not the hub. Public groups admit anyone — no invite. */
+ isAdmin && group?.join_policy !== 'open' && html`
<div class="settings-section">
<h3 class="settings-heading">${t('members.invite_title')}</h3>
${!connected && html`
@@ -2696,9 +3034,96 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
- ${/* Before the roster, not after it: this is what someone came here to
- do, and a list of two hundred names is a long way to scroll for
- it. */ html`
+ ${/* Roots management (Electron-only, when node is local) */
+ nodeDetected && nodeRoots.length > 0 && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings_node.roots')}</h3>
+ ${nodeMsg && html`<p class="settings-hint">${nodeMsg}</p>`}
+ <div class="node-roots">
+ ${nodeRoots.map(r => html`
+ <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}"
+ key=${r.name}>
+ <div class="node-root-info">
+ <span class="node-root-name">
+ <${Icon} name="folder" />
+ ${r.name}
+ </span>
+ ${r.upload && html`
+ <span class="node-root-badge">${t('node.upload_root')}</span>`}
+ ${!r.available && html`
+ <span class="node-root-badge node-root-badge-warn">
+ ${t('node.unavailable')}</span>`}
+ </div>
+ ${nodeRoots.length > 1 && !r.upload && html`
+ <button class="btn btn-small btn-danger"
+ disabled=${nodeBusy}
+ onClick=${async () => {
+ if (!confirm(t('node.root_remove_confirm', { name: r.name }))) return;
+ setNodeBusy(true); setNodeMsg('');
+ try {
+ await platform.node.call('DELETE',
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(r.name));
+ await platform.node.call('POST', '/api/reload');
+ setNodeMsg(t('node.root_removed'));
+ await loadNodeInfo();
+ } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
+ finally { setNodeBusy(false); }
+ }}>
+ ${t('node.remove_root')}</button>`}
+ </div>
+ `)}
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${nodeBusy}
+ onClick=${async () => {
+ const chosen = await platform.rootPicker.choose();
+ if (!chosen) return;
+ setNodeBusy(true); setNodeMsg('');
+ try {
+ await platform.node.call('POST',
+ '/api/groups/' + groupId + '/roots',
+ { path: chosen.path, name: chosen.name });
+ await platform.node.call('POST', '/api/reload');
+ setNodeMsg(t('node.root_added'));
+ await loadNodeInfo();
+ } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
+ finally { setNodeBusy(false); }
+ }}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
+ </button>
+ </div>
+ </div>
+ `}
+
+ ${/* Upload toggle via loopback when MNP not connected */
+ nodeDetected && !connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.uploads_title')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">
+ ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
+ </span>
+ <button class="admin-btn" disabled=${nodeBusy}
+ onClick=${async () => {
+ setNodeBusy(true); setNodeMsg('');
+ try {
+ const newVal = !memberUpload;
+ await platform.node.call('PUT',
+ '/api/groups/' + groupId + '/member-upload',
+ { allowed: newVal });
+ if (onMemberUpload) onMemberUpload(newVal);
+ } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
+ finally { setNodeBusy(false); }
+ }}>
+ ${memberUpload ? t('members.uploads_disable')
+ : t('members.uploads_enable')}
+ </button>
+ </div>
+ <p class="settings-hint">${t('members.uploads_hint')}</p>
+ </div>
+ `}
+
+ ${/* Delete/leave — node detach first (reversible), then hub delete
+ (irreversible). */ html`
<div class="settings-section">
<h3 class="settings-heading">
${isOwner ? t('group.delete_group') : t('group.leave')}
@@ -2713,6 +3138,15 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
<button class="admin-btn danger" onClick=${async () => {
if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return;
try {
+ // Node detach first (reversible), then hub delete (irreversible)
+ if (nodeDetected && nodeGroupName) {
+ try {
+ await platform.node.call('POST', '/api/groups/detach',
+ { name: nodeGroupName });
+ } catch (detachErr) {
+ if (!confirm(t('settings_node.detach_failed_continue'))) return;
+ }
+ }
await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token });
navigate('/');
window.location.reload();
@@ -2725,9 +3159,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
try {
await hubFetch('/v1/groups/' + groupId + '/leave',
{ method: 'POST', token });
- // Dropped from the list here rather than reloading: a
- // reload would tear down the WebRTC connections other
- // groups hold.
if (onLeft) onLeft(groupId);
} catch (err) { setError(err.message); }
}}>${t('group.leave')}</button>
@@ -2899,7 +3330,7 @@ function ChatImage({ filename, entries, transportRef, gekRef }) {
}
function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
- onPreview, mayUpload = true }) {
+ onPreview, mayUpload = true, onActivity }) {
const [messages, setMessages] = useState([]);
const [hasMore, setHasMore] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
@@ -3072,6 +3503,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
thread_id: null,
}]);
jumpToBottom();
+ if (onActivity) onActivity();
} catch {
setInput(text);
} finally {
@@ -5248,7 +5680,7 @@ function NodePage({ token, username, userId, groups }) {
if (status === 'idle' || status === 'connecting') {
return html`<div class="page-content">
- <p class="page-message">${t('status.connecting')}</p>
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p>
</div>`;
}
if (status === 'no_groups') {
@@ -5306,9 +5738,8 @@ function NodePage({ token, username, userId, groups }) {
${!r.available && html`
<span class="node-root-badge node-root-badge-warn">
${t('node.unavailable')}</span>`}
- <span class="node-root-kind">${r.kind}</span>
</div>
- ${(g.roots || []).length > 1 && html`
+ ${(g.roots || []).length > 1 && !r.upload && html`
<button class="btn btn-small btn-danger"
disabled=${busy} onClick=${() => removeRoot(g.id, r.name)}>
${t('node.remove_root')}</button>`}
@@ -5322,11 +5753,11 @@ function NodePage({ token, username, userId, groups }) {
<div class="node-section">
<span class="settings-heading">${t('node.gek')}</span>
- ${g.has_gek ? html`
+ ${g.has_gek ? (g.visibility !== 'public' ? html`
<button class="btn btn-small btn-secondary" disabled=${busy}
onClick=${() => rotateGek(g.id)}>
${t('node.gek_rotate')}</button>
- ` : html`
+ ` : html`<p class="node-hint">${t('node.gek_public_hint')}</p>`) : html`
<p class="node-hint">${t('node.gek_init_hint')}</p>
`}
</div>
@@ -5433,7 +5864,9 @@ function NodePage({ token, username, userId, groups }) {
`}
</div>
- ${(() => {
+ ${/* In Electron, the Create Group wizard handles attaching. Keep for
+ browser users who manage nodes via MNP. */
+ !platform.node.available && (() => {
const hostedIds = new Set(nodeGroups.map(g => g.id));
const unhosted = (groups || []).filter(g => !hostedIds.has(g.id));
if (!unhosted.length) return null;
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 4193f00..a8392c7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -125,7 +125,7 @@ export default {
// Status
'status.idle': 'Idle',
'status.discovering': 'Finding nodes...',
- 'status.connecting': 'Connecting via WebRTC...',
+ 'status.connecting': 'Connecting...',
'status.connecting_short': 'Connecting...',
'status.fetching': 'Fetching index...',
'status.files': {
@@ -332,6 +332,36 @@ export default {
'create_group.hint': 'A group needs a node to host files. You can create the group now and connect a node later.',
'create_group.creating': 'Creating...',
+ // Create Group Wizard (Electron-only)
+ 'wizard.title': 'Create Group',
+ 'wizard.detecting': 'Detecting local node...',
+ 'wizard.node_not_found': 'No local node detected. Make sure meshbay-node is running.',
+ 'wizard.retry': 'Retry',
+ 'wizard.skip_node': 'Continue without node',
+ 'wizard.node_offline_warning': 'Node is not running. The group will be created on the hub only. You can connect the node later from the Node page.',
+ 'wizard.directories': 'Shared directories',
+ 'wizard.directories_hint': 'Choose the directories this group will share. At least one is required.',
+ 'wizard.add_directory': 'Add directory',
+ 'wizard.upload_target': 'upload target',
+ 'wizard.set_upload': 'Set as upload target',
+ 'wizard.remove': 'Remove',
+ 'wizard.create_and_setup': 'Create and set up',
+ 'wizard.create_hub_only': 'Create on hub only',
+ 'wizard.setting_up': 'Setting up your group...',
+ 'wizard.step_create_hub': 'Creating group on hub',
+ 'wizard.step_attach': 'Attaching to node',
+ 'wizard.step_add_roots': 'Adding directories',
+ 'wizard.step_gek': 'Initializing encryption key',
+ 'wizard.step_pair': 'Setting up pairing',
+ 'wizard.finish_later': 'Finish setup later',
+ 'wizard.done_title': 'Group created',
+ 'wizard.done_message': 'Your group is ready. Your node is hosting it and encryption is set up.',
+ 'wizard.go_to_group': 'Go to group',
+
+ // Unified Group Settings (node sections)
+ 'settings_node.roots': 'Shared directories',
+ 'settings_node.detach_failed_continue': 'Could not detach the group from the node. Delete on hub anyway?',
+
// Members
'members.col_role': 'Role',
'members.group_role': 'Group role',
@@ -469,6 +499,7 @@ export default {
'node.gek_rotate_confirm': 'Rotate the group key? Connected members will receive the new key automatically. Content already downloaded is unaffected.',
'node.gek_rotated': 'Group key rotated.',
'node.gek_init_hint': 'No group key yet. Run GEK init from the CLI to set one up.',
+ 'node.gek_public_hint': 'Key rotation is not available for public groups.',
'node.roster': 'Roster',
'node.roster_load': 'Load roster',
'node.roster_empty': 'No members pinned yet.',
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 fe6c666..9eb2f30 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -125,7 +125,7 @@ export default {
// Status
'status.idle': 'Inactif',
'status.discovering': 'Recherche de nodes...',
- 'status.connecting': 'Connexion via WebRTC...',
+ 'status.connecting': 'Connexion…',
'status.connecting_short': 'Connexion…',
'status.fetching': "Récupération de l'index...",
'status.files': {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index d379e9d..02107f5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -207,8 +207,35 @@ export const rootPicker = {
async choose() { return bridge && bridge.rootPicker ? bridge.rootPicker.choose() : null; },
};
+/**
+ * The local node, if one is running on this machine.
+ *
+ * Detection probes `127.0.0.1:{ui_port}` with the session token read from the
+ * daemon's data directory. The renderer never sees the token — it names an
+ * operation and the main process executes it, the same trust model as hub:fetch.
+ *
+ * In a browser all calls resolve to a "not available" result, so the interface
+ * can gate features on `node.available` without a build flag.
+ */
+export const node = {
+ available: Boolean(bridge && bridge.node),
+ async detect() {
+ return bridge && bridge.node ? bridge.node.detect() : { detected: false };
+ },
+ async call(method, path, body) {
+ if (!bridge || !bridge.node) throw new Error('Node bridge not available');
+ return bridge.node.call(method, path, body);
+ },
+ async pairingCode() {
+ return bridge && bridge.node ? bridge.node.pairingCode() : null;
+ },
+ async setPairingCode(code) {
+ return bridge && bridge.node ? bridge.node.setPairingCode(code) : false;
+ },
+};
+
export default { isNative, hubBase, capabilities, secrets, nativeSave,
- apiFetch, device, bridgeMessage, folder, rootPicker };
+ apiFetch, device, bridgeMessage, folder, rootPicker, node };
// Also a global, because `transport.js` is loaded as a classic script — it
// predates the module graph and exposes `MeshBayTransport` the same way. The
@@ -217,5 +244,5 @@ export default { isNative, hubBase, capabilities, secrets, nativeSave,
if (typeof window !== 'undefined') {
window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets,
nativeSave, apiFetch, device,
- bridgeMessage, folder, rootPicker };
+ bridgeMessage, folder, rootPicker, node };
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index ac3d08f..7ce073a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2222,3 +2222,71 @@ a.transfer-name {
align-items: center;
}
.node-attach-dir-row input { flex: 1; }
+
+/* ── Create Group Wizard ──────────────────────────────────────────────────── */
+
+.wizard-root {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ gap: 8px;
+}
+.wizard-root + .wizard-root { margin-top: 6px; }
+.wizard-root-info {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ min-width: 0;
+}
+.wizard-root-name { font-weight: 500; }
+.wizard-root-path {
+ font-size: 0.82em;
+ color: var(--text-dim);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.wizard-root-actions {
+ display: flex;
+ gap: 4px;
+ flex-shrink: 0;
+}
+
+.wizard-progress {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 16px;
+}
+.wizard-step {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ font-size: 0.9em;
+}
+.wizard-step-icon {
+ width: 20px;
+ text-align: center;
+ font-weight: 600;
+}
+.wizard-step-done .wizard-step-icon { color: var(--success); }
+.wizard-step-running .wizard-step-icon { color: var(--accent); }
+.wizard-step-error .wizard-step-icon { color: var(--error); }
+.wizard-step-pending { opacity: 0.5; }
+
+.warning-msg {
+ padding: 10px 14px;
+ background: color-mix(in srgb, #f59e0b 15%, transparent);
+ border: 1px solid #f59e0b;
+ border-radius: 8px;
+ font-size: 0.85em;
+}
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 7e3ebc1..385a1da 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -125,6 +125,7 @@ class NodeDaemon:
self._indexers: list[DirectoryIndexer] = []
self._tasks: list[asyncio.Task] = []
self._hub: HubClient | None = None
+ self._reload_lock = asyncio.Lock()
async def run(self) -> None:
log.info("MeshBay Node starting up")
@@ -225,14 +226,13 @@ class NodeDaemon:
", ".join(str(r.path) for r in roots))
gek = None
- if group_cfg.visibility == "private":
- gek = await self._load_gek(
- group_cfg.id, session.user_id, sk_x_raw, pk_x_raw)
- if gek:
- log.info("GEK loaded for group %s", group_cfg.id[:8])
- else:
- log.info("No GEK yet for group %s — will accept first setup",
- group_cfg.name)
+ gek = await self._load_gek(
+ group_cfg.id, session.user_id, sk_x_raw, pk_x_raw)
+ if gek:
+ log.info("GEK loaded for group %s", group_cfg.id[:8])
+ else:
+ log.info("No GEK yet for group %s — will accept first setup",
+ group_cfg.name)
indexer = DirectoryIndexer(
roots=roots,
@@ -399,7 +399,7 @@ class NodeDaemon:
on_incoming=on_incoming,
on_revocation=on_revocation,
on_webrtc_offer=on_webrtc_offer,
- group_ids=list(groups_ctx.keys()),
+ group_ids=lambda: list((self._state.get("groups_ctx") or {}).keys()),
))
self._tasks.append(ws_task)
log.info("Hub WS task started")
@@ -470,7 +470,15 @@ class NodeDaemon:
Handles root changes on existing groups, hot-loads new groups, and
tears down removed groups. Existing connections are untouched: a member
watching a film keeps watching it.
+
+ Serialised by _reload_lock: fire-and-forget reloads from config-mutating
+ endpoints can overlap with the wizard's explicit /api/reload call,
+ and two concurrent hot-loads of the same group corrupt the runtime state.
"""
+ async with self._reload_lock:
+ await self._reload_config_inner()
+
+ async def _reload_config_inner(self) -> None:
log.info("Reloading config from %s", self._config_path)
try:
fresh = load_config(self._config_path)
@@ -537,7 +545,7 @@ class NodeDaemon:
roots.refresh_availability()
gek = None
- if group_cfg.visibility == "private" and sk_x_raw and pk_x_raw:
+ if sk_x_raw and pk_x_raw:
gek = await self._load_gek(
group_cfg.id, node_user_id, sk_x_raw, pk_x_raw)
if gek:
@@ -616,6 +624,10 @@ class NodeDaemon:
log.info("Reload complete — %d re-rooted, %d added, %d removed",
changed, len(added_names), len(removed_names))
+ if (added_names or removed_names) and self._hub:
+ gids = list((self._state.get("groups_ctx") or {}).keys())
+ await self._hub.update_ws_groups(gids)
+
async def _login_with_retry(self, hub: HubClient):
"""Login to hub, retrying if the node key hasn't been linked yet."""
import httpx as _httpx
diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py
index b345187..92c39db 100644
--- a/packages/meshbay-node/src/meshbay_node/hub_client.py
+++ b/packages/meshbay-node/src/meshbay_node/hub_client.py
@@ -229,12 +229,24 @@ class HubClient:
except Exception:
pass
+ async def update_ws_groups(self, group_ids: list[str]) -> None:
+ """Tell the hub about changed group list without dropping the connection."""
+ ws = self._ws
+ if ws:
+ try:
+ await ws.send(json.dumps({
+ "type": "update_groups",
+ "group_ids": group_ids,
+ }))
+ except Exception:
+ pass
+
async def maintain_ws(
self,
on_incoming: Any = None,
on_revocation: Any = None,
on_webrtc_offer: Any = None,
- group_ids: list[str] | None = None,
+ group_ids: list[str] | None = None, # static list or callable returning one
) -> None:
"""
Maintain a persistent WebSocket connection to the hub.
@@ -267,8 +279,9 @@ class HubClient:
"token": self._session.access_token,
"node_id": self._session.node_id,
}
- if group_ids:
- auth_msg["group_ids"] = group_ids
+ gids = group_ids() if callable(group_ids) else group_ids
+ if gids:
+ auth_msg["group_ids"] = gids
await ws.send(json.dumps(auth_msg))
# Bounded: a hub that accepts the socket and then says nothing
# — which is what it does for a few seconds while restarting —
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index 2a76290..daddf17 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -157,38 +157,44 @@ async def pair_operator(state: dict) -> dict:
return {"code": code, "expires_at": expires, "user_id": user_id}
-async def create_invite(state: dict, group_id: str, username: str) -> dict:
+async def create_invite(state: dict, group_id: str, username: str, *,
+ user_id: str = "",
+ created_by: str = "local-cli") -> dict:
"""
Issue an invitation code.
The hub is asked for the account id and nothing else — never for a key. A hub
that answered with the wrong account would produce an invite whose code it
never learns, since the code goes to a human out of band.
+
+ When ``user_id`` is supplied directly (MNP path), the hub lookup is skipped.
"""
roster = _roster(state)
_group_ctx(state, group_id)
- hub = _hub(state)
- try:
- account = await hub.get_user_pubkeys(username)
- except Exception as e:
- raise OpError(f"Unknown user {username!r}: {e}", status=404) from e
+ if not user_id:
+ hub = _hub(state)
+ try:
+ account = await hub.get_user_pubkeys(username)
+ except Exception as e:
+ raise OpError(f"Unknown user {username!r}: {e}", status=404) from e
+ user_id = account["user_id"]
config = state.get("config")
ttl = (config.node.invite_ttl_hours if config else 168) * 3600
code = await roster.create_invite(
group_id=group_id,
- user_id=account["user_id"],
+ user_id=user_id,
role=ROLE_MEMBER,
- created_by="local-cli",
+ created_by=created_by,
ttl=ttl,
username=username,
)
invites = await roster.list_invites()
expires = next((i["expires_at"] for i in invites
- if i["user_id"] == account["user_id"]
+ if i["user_id"] == user_id
and i["group_id"] == group_id), "")
return {"code": code, "expires_at": expires,
- "username": username, "user_id": account["user_id"]}
+ "username": username, "user_id": user_id}
async def revoke_member(state: dict, user_id: str, group_id: str) -> dict:
@@ -236,6 +242,10 @@ async def set_gek(state: dict, group_id: str, *, rotate: bool = False) -> dict:
ctx = _group_ctx(state, group_id)
hub = _hub(state)
+ if rotate and ctx.get("visibility") == "public":
+ raise OpError(
+ "Key rotation is not available for public groups", status=400)
+
bundle_store = state.get("bundle_store")
if not bundle_store:
raise OpError("Bundle store not available", status=503)
@@ -361,20 +371,25 @@ async def attach_group(state: dict, name: str, shared_dir: str,
# Appended as text rather than re-serialised: node.toml is hand-written and
# full of comments explaining decisions, and a round trip through a TOML
# writer would throw all of that away.
+ join_policy = group.get("join_policy", "invite")
block = (f'\n[[groups]]\n'
f'id = "{group["id"]}"\n'
f'name = "{group["name"]}"\n'
- f'visibility = "{group.get("visibility", "private")}"\n')
+ f'visibility = "{group.get("visibility", "private")}"\n'
+ f'join_policy = "{join_policy}"\n')
+ separate_upload = False
if upload_dir:
- upload_path = Path(upload_dir).expanduser()
- try:
- upload_path.mkdir(parents=True, exist_ok=True)
- except OSError as e:
- raise OpError(f"Cannot create {upload_path}: {e}") from e
- block += f'upload_dir = "{upload_path}"\n'
+ upload_path = Path(upload_dir).expanduser().resolve()
+ if upload_path != path.resolve():
+ separate_upload = True
+ try:
+ upload_path.mkdir(parents=True, exist_ok=True)
+ except OSError as e:
+ raise OpError(f"Cannot create {upload_path}: {e}") from e
+ block += f'upload_dir = "{upload_path}"\n'
block += (f'\n [[groups.roots]]\n'
f' path = "{path}"\n')
- if not upload_dir:
+ if not separate_upload:
block += f' upload = true\n'
try:
with conf_path.open("a") as f:
@@ -385,7 +400,7 @@ async def attach_group(state: dict, name: str, shared_dir: str,
result = {"group_id": group["id"], "name": group["name"],
"shared_dir": str(path), "config": str(conf_path),
"note": "restart the node to pick it up"}
- if upload_dir:
+ if separate_upload:
result["upload_dir"] = str(upload_path)
return result
@@ -552,9 +567,6 @@ async def add_root(state: dict, group_id: str, path: str, *,
cfg.roots.append(RootSpec(
path=str(added.path), name=added.name, kind=added.kind,
upload=added.upload, direct=added.direct))
- groups_ctx = state.get("groups_ctx", {})
- if group_id in groups_ctx:
- groups_ctx[group_id]["roots"] = built
log.info("Root added: %s → group %s", added.name, group_id[:8])
return {"status": "added", "name": added.name, "path": str(added.path),
@@ -602,15 +614,10 @@ async def remove_root(state: dict, group_id: str, root_name: str) -> dict:
built = RootSet.build(remaining)
except RootError:
built = None
- if built is not None:
- groups_ctx = state.get("groups_ctx", {})
- if group_id in groups_ctx:
- groups_ctx[group_id]["roots"] = built
log.info("Root removed: %s from group %s", root_name, group_id[:8])
return {"status": "removed", "name": root_name, "group_id": group_id,
- "roots": built.describe() if built else [],
- "note": "restart recommended to update the file index"}
+ "roots": built.describe() if built else []}
# ── Files ────────────────────────────────────────────────────────────────────
@@ -682,3 +689,34 @@ async def clear_denylist(state: dict, *, subject: str = "") -> dict:
log.warning("Denylist cleared (%s): %d entr(y/ies) removed",
subject or "all", removed)
return {"status": "cleared", "removed": removed, "subject": subject or "all"}
+
+
+# ── Upload policy ───────────────────────────────────────────────────────────
+
+async def set_member_upload(state: dict, group_id: str, allowed: bool) -> dict:
+ """
+ Turn uploading by ordinary members on or off.
+
+ The setting lives on the node (roster.db), not on the hub and not in
+ node.toml — changing it must not rewrite the operator's config file,
+ and must not need a restart.
+ """
+ roster = _roster(state)
+ ctx = _group_ctx(state, group_id)
+ await roster.set_member_upload(group_id, allowed,
+ set_by=state.get("node_user_id", ""))
+ ctx["member_upload"] = allowed
+ log.info("Upload policy: %s for group %s", "on" if allowed else "off",
+ group_id[:8])
+ return {"allowed": allowed, "group_id": group_id}
+
+
+# ── Reload ──────────────────────────────────────────────────────────────────
+
+async def reload_config(state: dict) -> dict:
+ """Hot-reload node.toml without dropping connections."""
+ reload_fn = state.get("reload_fn")
+ if not reload_fn:
+ raise OpError("Reload not available", status=503)
+ await reload_fn()
+ return {"status": "reloaded"}
diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py
index bc27bf7..74ea2f6 100644
--- a/packages/meshbay-node/src/meshbay_node/roots.py
+++ b/packages/meshbay-node/src/meshbay_node/roots.py
@@ -29,6 +29,7 @@ are one directory.
from __future__ import annotations
import logging
+import re
from dataclasses import dataclass, field
from pathlib import Path
@@ -38,6 +39,75 @@ log = logging.getLogger(__name__)
VALID_KINDS = ("generic", "video", "audio", "photo")
+# Filenames and subdirectory names sent by clients. A leading dot is a hidden
+# file on every platform, a leading hyphen confuses CLI tools, a leading space
+# cannot start one, and a trailing space or dot is refused because it makes two
+# different files look identical in a list.
+SAFE_UPLOAD_NAME = re.compile(
+ r"^[^\W_]" # letter or digit — never ‘.’, ‘-’ or space
+ r"[\w .\-()\[\]'\u2019,&+#@]{0,127}" # body: word chars plus mild punctuation
+ r"(?<![ .])$", # and never ending on a space or a dot
+ re.UNICODE)
+
+
+def _free_name(directory: Path, filename: str) -> str:
+ """
+ `filename`, or the first "name (n).ext" that is not taken.
+
+ Never returns the name of a file that exists, so an upload cannot replace
+ one — the property the per-user quarantine used to provide (C5a).
+ """
+ if not (directory / filename).exists():
+ return filename
+ stem, dot, ext = filename.rpartition(".")
+ if not dot:
+ stem, ext = filename, ""
+ for n in range(2, 1000):
+ candidate = f"{stem} ({n}){dot}{ext}"
+ if not (directory / candidate).exists():
+ return candidate
+ raise FileExistsError(filename)
+
+
+def safe_subdir(roots: "RootSet", rel: str) -> Path | None:
+ """
+ Resolve a client-supplied directory inside one of the group's roots, or refuse.
+
+ The path arrives from the wire, so every part is checked: the first segment
+ must name a root that is readable right now, each later segment against the
+ same allowlist as filenames, and the resolved result against that root's
+ directory. `..`, absolute paths, symlinks pointing out, and anything with a
+ separator in a segment are all refused here rather than in the caller, so
+ there is one place to get it right.
+
+ The virtual root itself — `""` — is deliberately **not** resolvable. It is
+ not a directory on anyone's disk: a file cannot be written there and a
+ directory cannot be created there, because it belongs to no volume. Callers
+ that used to receive the shared root for an empty path now receive None,
+ which is the honest answer.
+
+ The quarantine was the fix for C5a; what actually mattered in it — no
+ overwrite, a name allowlist, and confinement — is kept by this plus the
+ caller's existing checks.
+ """
+ found = roots.split(rel or "")
+ if found is None:
+ return None
+ root, tail = found
+ if not root.available:
+ return None
+ parts = [seg for seg in tail.split("/") if seg not in ("", ".")]
+ if any(seg == ".." or not SAFE_UPLOAD_NAME.match(seg) for seg in parts):
+ return None
+ try:
+ target = (root.path / Path(*parts)).resolve() if parts else root.path.resolve()
+ base = root.path.resolve()
+ except OSError:
+ return None
+ if target != base and base not in target.parents:
+ return None
+ return target
+
class RootError(ValueError):
"""A root set that cannot be built. The message is shown to the operator."""
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 1f1f2d2..947d1f9 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -28,7 +28,6 @@ import hashlib
import hmac
import logging
import os
-import re
import struct
import time
from pathlib import Path
@@ -86,8 +85,9 @@ from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes
from meshbay_common.protocol import MNP
from meshbay_node.indexer import GroupIndex
from meshbay_node import ops
-from meshbay_node.roots import RootSet, entry_abs_path
-from meshbay_node.roster import DEFAULT_INVITE_TTL
+from meshbay_node.roots import (
+ RootSet, entry_abs_path, SAFE_UPLOAD_NAME, safe_subdir, _free_name,
+)
log = logging.getLogger(__name__)
@@ -135,81 +135,6 @@ JOIN_FAILURE_WINDOW = 600 # seconds
# into, back up or empty — rather than a hidden tree of per-user uuids that
# nobody could read, or files scattered wherever someone happened to be looking.
UPLOAD_DIR_NAME = "uploads"
-# Conservative allowlist: also what keeps markup out of filenames, which the node admin
-# UI used to render unescaped (finding H2).
-# An allowlist, still — C5a and H2 depend on it — but one that does not assume
-# the world writes in ASCII. `été.txt` and `rapport (1).pdf` were refused, and
-# the second of those is a name _free_name generates itself, so the node was
-# rejecting files it had named. `\w` is Unicode here, which admits letters and
-# digits of any script while `<`, `>`, `"`, `;`, `/`, `\` and control characters
-# stay out. The first character must be a letter or digit, so ".." and dotfiles
-# cannot start one, and a trailing space or dot is refused because it makes two
-# different files look identical in a list.
-SAFE_UPLOAD_NAME = re.compile(
- r"^[^\W_]" # letter or digit — never '.', '-' or space
- r"[\w .\-()\[\]'\u2019,&+#@]{0,127}" # body: word chars plus mild punctuation
- r"(?<![ .])$", # and never ending on a space or a dot
- re.UNICODE)
-
-
-def _free_name(directory: Path, filename: str) -> str:
- """
- `filename`, or the first "name (n).ext" that is not taken.
-
- Never returns the name of a file that exists, so an upload cannot replace
- one — the property the per-user quarantine used to provide (C5a).
- """
- if not (directory / filename).exists():
- return filename
- stem, dot, ext = filename.rpartition(".")
- if not dot:
- stem, ext = filename, ""
- for n in range(2, 1000):
- candidate = f"{stem} ({n}){dot}{ext}"
- if not (directory / candidate).exists():
- return candidate
- raise FileExistsError(filename)
-
-
-def safe_subdir(roots: RootSet, rel: str) -> Path | None:
- """
- Resolve a client-supplied directory inside one of the group's roots, or refuse.
-
- The path arrives from the wire, so every part is checked: the first segment
- must name a root that is readable right now, each later segment against the
- same allowlist as filenames, and the resolved result against that root's
- directory. `..`, absolute paths, symlinks pointing out, and anything with a
- separator in a segment are all refused here rather than in the caller, so
- there is one place to get it right.
-
- The virtual root itself — `""` — is deliberately **not** resolvable. It is
- not a directory on anyone's disk: a file cannot be written there and a
- directory cannot be created there, because it belongs to no volume. Callers
- that used to receive the shared root for an empty path now receive None,
- which is the honest answer.
-
- The quarantine was the fix for C5a; what actually mattered in it — no
- overwrite, a name allowlist, and confinement — is kept by this plus the
- caller's existing checks.
- """
- found = roots.split(rel or "")
- if found is None:
- return None
- root, tail = found
- if not root.available:
- return None
- parts = [seg for seg in tail.split("/") if seg not in ("", ".")]
- if any(seg == ".." or not SAFE_UPLOAD_NAME.match(seg) for seg in parts):
- return None
- try:
- target = (root.path / Path(*parts)).resolve() if parts else root.path.resolve()
- base = root.path.resolve()
- except OSError:
- return None
- if target != base and base not in target.parents:
- return None
- return target
-
def _extract_dtls_fingerprint(sdp: str) -> bytes:
@@ -985,6 +910,12 @@ class WebRTCPeerSession:
# the client is told it has no role on a node it administers.
member = (await roster.get_member(group_id, user_id)
or await roster.get_member("", user_id))
+ if not member and self._group_join_policy(session_group) == "open":
+ await roster.set_member(
+ group_id=session_group, user_id=user_id, role=ROLE_MEMBER,
+ status="active", approved_by="open-join",
+ )
+ member = await roster.get_member(session_group, user_id)
await self._join_ok(
user_id, pk_x_raw, session_group,
role=member["role"] if member else "",
@@ -1635,16 +1566,12 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "Signature verification failed"})
self._audit("admin_auth_failed", f"member_upload:{pending['subject']}")
return
- roster = self._ctx.get("roster")
- if roster is None:
- self._send({"type": "error", "detail": "No roster on this node"})
+ try:
+ await self._run_op(
+ ops.set_member_upload, self._group_id or "", allowed)
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
return
- await roster.set_member_upload(self._group_id or "", allowed,
- set_by=self._user_id)
- # Stored *and* applied. The upload path is synchronous and reads this
- # dict; leaving it to the next restart would make the panel say one
- # thing while the node did another.
- self._group_ctx()["member_upload"] = allowed
self._audit("member_upload", pending["subject"])
# Everyone already connected is told, rather than finding out by having
@@ -1928,14 +1855,11 @@ class WebRTCPeerSession:
self._audit("admin_auth_failed", f"member_revoke:{user_id[:8]}")
return
- roster = self._ctx.get("roster")
- if roster is None:
- self._send({"type": "error", "detail": "Roster not available"})
- return
-
- group_id = self._group_id or ""
- if not await roster.set_status(group_id, user_id, "revoked"):
- self._send({"type": "error", "detail": "Not a member of this group"})
+ try:
+ result = await self._run_op(
+ ops.revoke_member, user_id, self._group_id or "")
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
return
# Anyone connected right now keeps the key they already unwrapped; what
@@ -1948,14 +1872,11 @@ class WebRTCPeerSession:
except Exception:
pass
- log.info("Member revoked by %s: user=%s group=%s",
- self._user_id[:8], user_id[:8], group_id[:8] or "-")
self._audit("member_revoke", user_id)
self._send({
"type": MNP.MEMBER_REVOKE_ACK, "v": MNP_VERSION,
"user_id": user_id,
- "reminder": "they still hold the current group key — rotate it with "
- "meshbay-node gek-init",
+ "reminder": result.get("reminder", ""),
})
async def _do_keypair_bundle_delete(self) -> None:
@@ -2694,37 +2615,27 @@ class WebRTCPeerSession:
self._audit("admin_auth_failed", f"invite_create:{pending['subject'][:16]}")
return
- roster = self._ctx.get("roster")
- if roster is None:
- self._send({"type": "error", "detail": "Roster not available"})
- return
-
payload = pending["payload"]
- code = await roster.create_invite(
- group_id=payload["group_id"],
- user_id=payload["user_id"],
- role=ROLE_MEMBER,
- created_by=self._user_id or "",
- ttl=self._ctx.get("invite_ttl", DEFAULT_INVITE_TTL),
- username=payload.get("username", ""),
- )
- invites = await roster.list_invites()
- expires = next(
- (i["expires_at"] for i in invites
- if i["user_id"] == payload["user_id"]
- and i["group_id"] == payload["group_id"]), "")
+ try:
+ result = await self._run_op(
+ ops.create_invite,
+ payload["group_id"],
+ payload.get("username", ""),
+ user_id=payload["user_id"],
+ created_by=self._user_id or "",
+ )
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
- log.info("Invite created: group=%s user=%s",
- payload["group_id"][:8], payload["user_id"][:8])
self._audit("invite_create", f"target={payload['user_id'][:8]}")
- # The code exists in the clear exactly here and in the operator's hands.
self._send({
"type": MNP.INVITE_RESULT,
"v": MNP_VERSION,
- "code": code,
- "expires_at": expires,
- "user_id": payload["user_id"],
- "username": payload.get("username", ""),
+ "code": result["code"],
+ "expires_at": result["expires_at"],
+ "user_id": result["user_id"],
+ "username": result.get("username", ""),
})
def _exec_file_delete(self, ctx: dict, file_id: str, entry) -> None:
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index a068a8d..b505f25 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -295,6 +295,44 @@ def create_ui_app(state: dict) -> FastAPI:
async def init_gek(group_id: str, rotate: bool = False):
return await _op(lambda: ops.set_gek(state, group_id, rotate=rotate))
+ # ── Roots management (operator only, localhost) ────────────────────────
+
+ @app.post("/api/groups/{group_id}/roots")
+ async def add_root(group_id: str, payload: dict):
+ result = await _op(lambda: ops.add_root(
+ state, group_id,
+ (payload.get("path") or "").strip(),
+ name=(payload.get("name") or "").strip(),
+ kind=(payload.get("kind") or "generic").strip(),
+ upload=bool(payload.get("upload", False)),
+ ))
+ reload_fn = state.get("reload_fn")
+ if reload_fn:
+ asyncio.ensure_future(reload_fn())
+ return result
+
+ @app.delete("/api/groups/{group_id}/roots/{root_name}")
+ async def remove_root(group_id: str, root_name: str):
+ result = await _op(lambda: ops.remove_root(state, group_id, root_name))
+ reload_fn = state.get("reload_fn")
+ if reload_fn:
+ asyncio.ensure_future(reload_fn())
+ return result
+
+ # ── Upload toggle (operator only, localhost) ─────────────────────────
+
+ @app.put("/api/groups/{group_id}/member-upload")
+ async def set_member_upload(group_id: str, payload: dict):
+ return await _op(lambda: ops.set_member_upload(
+ state, group_id, bool(payload.get("allowed", False)),
+ ))
+
+ # ── Reload config ────────────────────────────────────────────────────
+
+ @app.post("/api/reload")
+ async def reload_config():
+ return await _op(lambda: ops.reload_config(state))
+
# ── Chat endpoints ───────────────────────────────────────────────────────
_chat_subscribers: list[WebSocket] = []
diff --git a/packages/meshbay-node/tests/test_ops.py b/packages/meshbay-node/tests/test_ops.py
index f7fd259..d2ccc0d 100644
--- a/packages/meshbay-node/tests/test_ops.py
+++ b/packages/meshbay-node/tests/test_ops.py
@@ -63,7 +63,9 @@ def test_the_http_adapter_adds_no_logic():
source = inspect.getsource(ui)
# Every endpoint that performs an operation routes through _op(...).
for endpoint in ("operator_pair", "create_invite", "revoke_member",
- "unpin_member", "init_gek", "attach_group", "delete_file"):
+ "unpin_member", "init_gek", "attach_group", "delete_file",
+ "add_root", "remove_root", "set_member_upload",
+ "reload_config"):
start = source.index(f"async def {endpoint}(")
body = source[start:start + 700]
assert "_op(" in body.split("\n\n")[0] + body, (
@@ -177,3 +179,45 @@ async def test_an_unhosted_group_offers_what_it_does_host(tmp_path):
await ops.delete_file(state, "z" * 32, "a" * 64)
assert exc.value.status == 404
assert exc.value.extra.get("available")
+
+
+# ── Upload policy (set_member_upload) ───────────────────────────────────────
+
+async def test_set_member_upload_toggles_and_persists(tmp_path):
+ from meshbay_node.roster import Roster
+ state = _state(tmp_path)
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ state["roster"] = roster
+ state["node_user_id"] = "operator"
+
+ out = await ops.set_member_upload(state, "g" * 32, True)
+
+ assert out["allowed"] is True
+ assert state["groups_ctx"]["g" * 32]["member_upload"] is True
+
+ out2 = await ops.set_member_upload(state, "g" * 32, False)
+
+ assert out2["allowed"] is False
+ assert state["groups_ctx"]["g" * 32]["member_upload"] is False
+
+
+# ── Reload ──────────────────────────────────────────────────────────────────
+
+async def test_reload_config_calls_reload_fn(tmp_path):
+ state = _state(tmp_path)
+ called = []
+ async def fake_reload():
+ called.append(True)
+ state["reload_fn"] = fake_reload
+
+ out = await ops.reload_config(state)
+
+ assert out["status"] == "reloaded"
+ assert called
+
+
+async def test_reload_config_without_fn_is_refused(tmp_path):
+ state = _state(tmp_path)
+ with pytest.raises(ops.OpError, match="Reload not available"):
+ await ops.reload_config(state)
diff --git a/packages/meshbay-node/tests/test_roots.py b/packages/meshbay-node/tests/test_roots.py
index ea4ba6a..fc5bd64 100644
--- a/packages/meshbay-node/tests/test_roots.py
+++ b/packages/meshbay-node/tests/test_roots.py
@@ -11,7 +11,10 @@ from pathlib import Path
import pytest
-from meshbay_node.roots import Root, RootError, RootSet, entry_abs_path
+from meshbay_node.roots import (
+ Root, RootError, RootSet, entry_abs_path,
+ SAFE_UPLOAD_NAME, safe_subdir, _free_name,
+)
from meshbay_common.protocol import IndexEntry
@@ -240,3 +243,65 @@ def test_describe_reports_what_a_member_needs(tmp_path):
# Deliberately no paths: a member is told what exists and whether it is
# readable, not where on the operator's disk it lives.
assert not any("path" in d for d in described)
+
+
+# ── SAFE_UPLOAD_NAME ────────────────────────────────────────────────────────
+
+def test_safe_name_accepts_unicode_letters():
+ assert SAFE_UPLOAD_NAME.match("rapport (1).pdf")
+ assert SAFE_UPLOAD_NAME.match("hello.txt")
+
+
+def test_safe_name_rejects_dotfiles():
+ assert not SAFE_UPLOAD_NAME.match(".hidden")
+ assert not SAFE_UPLOAD_NAME.match("..secret")
+
+
+def test_safe_name_rejects_trailing_dot_or_space():
+ assert not SAFE_UPLOAD_NAME.match("file.")
+ assert not SAFE_UPLOAD_NAME.match("file ")
+
+
+# ── _free_name ──────────────────────────────────────────────────────────────
+
+def test_free_name_returns_original_when_not_taken(tmp_path):
+ assert _free_name(tmp_path, "photo.jpg") == "photo.jpg"
+
+
+def test_free_name_appends_counter_on_collision(tmp_path):
+ (tmp_path / "photo.jpg").write_text("x")
+ assert _free_name(tmp_path, "photo.jpg") == "photo (2).jpg"
+
+
+def test_free_name_increments_past_multiple_collisions(tmp_path):
+ (tmp_path / "photo.jpg").write_text("x")
+ (tmp_path / "photo (2).jpg").write_text("x")
+ assert _free_name(tmp_path, "photo.jpg") == "photo (3).jpg"
+
+
+# ── safe_subdir ─────────────────────────────────────────────────────────────
+
+def test_safe_subdir_resolves_valid_path(tmp_path):
+ (tmp_path / "Films").mkdir()
+ (tmp_path / "Films" / "2024").mkdir()
+ roots = RootSet.build([_spec(tmp_path / "Films")])
+ assert safe_subdir(roots, "Films/2024") == (tmp_path / "Films" / "2024").resolve()
+
+
+def test_safe_subdir_refuses_traversal(tmp_path):
+ (tmp_path / "Films").mkdir()
+ roots = RootSet.build([_spec(tmp_path / "Films")])
+ assert safe_subdir(roots, "Films/../../etc") is None
+
+
+def test_safe_subdir_refuses_empty_virtual_root(tmp_path):
+ (tmp_path / "Films").mkdir()
+ roots = RootSet.build([_spec(tmp_path / "Films")])
+ assert safe_subdir(roots, "") is None
+
+
+def test_safe_subdir_refuses_unavailable_root(tmp_path):
+ (tmp_path / "Films").mkdir()
+ roots = RootSet.build([_spec(tmp_path / "Films")])
+ roots.roots[0].available = False
+ assert safe_subdir(roots, "Films/2024") is None
diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py
index 61d53ac..7eb436c 100644
--- a/packages/meshbay-node/tests/test_roster_pairing.py
+++ b/packages/meshbay-node/tests/test_roster_pairing.py
@@ -91,6 +91,10 @@ def _session(tmp_path: Path, roster, user_id: str = "grenet",
session.sent = []
session._send = session.sent.append
session._audit = lambda *a, **k: None
+ session._ctx["daemon_state"] = {
+ "roster": roster,
+ "groups_ctx": session._ctx.get("groups", {}),
+ }
return session
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index 5727ef9..ec6e987 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -1134,6 +1134,10 @@ async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_di
transport._ctx["groups"] = {
TEST_GROUP: {"gek": gek, "roots": shared_dir, "index": indexer.index},
}
+ transport._ctx["daemon_state"] = {
+ "roster": roster,
+ "groups_ctx": transport._ctx["groups"],
+ }
# A paired operator, as `meshbay-node operator pair` would have left it.
sk_admin = Ed25519PrivateKey.generate()