diff options
22 files changed, 691 insertions, 3 deletions
@@ -246,6 +246,28 @@ keypair bundle, or anything that looks like a user's public key. the group named in the invitation (an operator pairs node-wide while opening a group) +## Where Phase 13 stands (2026-08-19) + +Stages A–D are **built and running**, not designed. `docs/desktop-client-v1.md` is still +the decision record; the sections below it describe what was decided, and this says what +exists. + +- Built: named roots, `ops.py` + CLI, device linking and hub device auth, the Electron + client (protocol handler, CSP header, `safeStorage` keys, streamed downloads to disk, + native folder picker), the group Settings tab, per-account resume positions, uploads + the operator can close +- Deployed: the hub runs MNP 0.3 with migration `e5a2b7d31f88`. **The SPA served in + production is older than this tree** — check `/a/<hash>/` against + `meshbay_hub.api.webapp.ASSET_V` before concluding a fix is missing. `site/` and the + Caddy config have never been deployed +- Not built: D5 (node management panel), D6 (first-run wizard), D8 (`.deb`/`.rpm` — the + package must ship `chrome-sandbox` root-owned 4755), D9 (Python sidecar `group://`), + D10 (video thumbnails), D11 (Windows), D12 (release key and repo) +- One UI source: `packages/meshbay-hub/src/meshbay_hub/static/` is the interface, for the + web and the app alike. `packages/meshbay-client/build/sync-ui.js` copies it and CI + fails if the copy drifts — **never edit `packages/meshbay-client/ui/` by hand** +- Throwaway `e2e*` accounts accumulate on the production hub; the operator deletes them + ## Desktop client — decided, not built (2026-08-17) See `docs/desktop-client-v1.md`. Nothing here is implemented; it is the design and the @@ -685,6 +707,7 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: | Streamed download (browser) | `static/downloads.js` + `static/sw.js` | Needs the page *controlled*, and the worker confirms it served the request | | Leave a group (hub) | `meshbay_hub.api.groups` | `POST /v1/groups/{id}/leave` — self only; the owner is refused | | Public group cap (hub) | `meshbay_hub.api.groups` | `_check_public_group_quota` — 10 live public groups per owner, staff exempt. **Checked at creation only, because PATCH refuses to change visibility** | +| Uploads on/off (node) | `meshbay_node.roster` + `transport.webrtc_server` | `member_upload_allowed` / `set_member_upload`, gate in `_do_file_upload`. Per group, **operator-signed** (`OP_MEMBER_UPLOAD`), stored in `roster.db`, cached in the group context because the upload path is synchronous. **Absent means allowed** at every layer | | Node presence (hub) | `meshbay_hub.api.groups` | `node_online` on `/v1/groups/mine`, read from the signaling registry — no poll, no timer | | Chat paging (node) | `meshbay_node.chat.store` | `get_recent` / `get_before` / `has_before`. `get_messages` pages *forwards* and is not what a chat opens with | | Liveness (MNP) | `meshbay_common.protocol` | `PING`/`PONG` on an **already-open** channel; never for discovery — a handshake costs 0.6-7 s | diff --git a/docs/desktop-client-v1.md b/docs/desktop-client-v1.md index c0bbb90..08dd548 100644 --- a/docs/desktop-client-v1.md +++ b/docs/desktop-client-v1.md @@ -1091,6 +1091,23 @@ Deletions enabled once native is the recommended client are unchanged from --- +## 10b. Group settings, and where they live + +The group tab that was "Members" is **"Settings"**, and it is where anything about the +group is administered: invitations, operator pairing, your devices on this node, whether +members may upload, leaving or deleting the group, and the roster — last, because it is +the only part with no upper bound. + +Two rules came out of building it and both are easy to undo by accident: + +- **The tab bar does not wait for the node.** Membership is hub-side. Gating the tab on a + live connection makes "leave this group" unreachable exactly when a node is down, which + is when someone most wants it. Sections that do need the node say so. +- **A setting that governs the node is stored on the node and enforced by the node.** + `member_upload` is the worked example: signed operator instruction, row in `roster.db`, + refusal in `_do_file_upload`. Hiding the Upload button and the chat paperclip is a + courtesy to people who are not trying; it is not the control. See draft-v6 §2.1b. + ## 11. Open items, each needing its own review | # | Item | Why it is not decided here | diff --git a/docs/meshbay-draft-v6.md b/docs/meshbay-draft-v6.md index 6a161cd..2ea73c4 100644 --- a/docs/meshbay-draft-v6.md +++ b/docs/meshbay-draft-v6.md @@ -42,6 +42,7 @@ | 6 | Portability | exFAT/NTFS and Windows are the **common** case. Case folding and Unicode normalization become correctness requirements, not compatibility notes | E8 / decision 12 | | 7 | Accounts | Native registration is **hybrid**: passphrase-derived `auth_key` (the recovery path) plus a device Ed25519 key for day-to-day authentication | E3 / decision 4 | | 8 | Authorship | Chat senders are **cryptographically authenticated to each other**; an upload has a **provable owner** who may delete it, as the operator may. v5's node-asserted attribution is replaced | operator decision, §2.4b | +| 9 | Node authority | The operator may **close uploading to everyone but themselves**, per group. Signed MNP op, stored on the node, enforced by the node — the hidden button is a courtesy, the refusal is the control | §2.1b | --- @@ -67,6 +68,39 @@ v5's rule that nothing derived is written beside the originals is **unchanged**, decides the video-thumbnail question: a frame grab is produced on demand and cached on the device that asked, so the node keeps no thumbnail store. +### 2.1b §5.2 Uploads — the operator may close them + +New. A group where every member may add files is the default and stays the default; +some groups want a library the operator curates, and until now the only way to get one +was to designate no upload root at all, which refuses the operator too. + +`member_upload` is a per-group setting, and three things about it are load-bearing: + +- **It lives on the node**, in `roster.db`, not in `node.toml` and not on the hub. Not + the hub because a hub that decides who may write to someone else's disk has authority + over that node, which is the arrangement this design exists to avoid (change 5). Not + `node.toml` because that file is hand-written, full of comments recording decisions, + and `ops.py` deliberately appends to it rather than round-tripping it through a + writer — a setting changed from a panel must not rewrite the operator's file, and must + not need a restart. +- **Changing it is a signed operator instruction** (`OP_MEMBER_UPLOAD`, MNP + `member_upload`), on the same path as removing a member. An unsigned one would let any + member turn it back on, which makes the control a suggestion. The transcript's subject + is `on` or `off` — what the operator is shown before signing has to name the outcome, + not the operation. +- **The node enforces it**; the interface merely stops offering it. `handshake_ack` + carries `member_upload` so a client knows whether to draw the Upload button and the + chat paperclip, and the node broadcasts `member_upload_ack` to everyone connected when + it changes. None of that is the control: a member on an old tab, or one speaking MNP + directly, is refused by the node with `member_upload_off`. + +**Absent means allowed**, at every layer — no row in `group_settings`, no key in the +group context, no field in the ack. A node or client that predates the setting behaves +exactly as it did, and an upgrade never silently closes a group. + +The operator is always exempt. Turning it off otherwise locks them out of their own +node, with a config file and a restart as the only way back. + ### 2.2 §5.5 Admission — devices, not one key per person v5 and `invite-pairing-v1.md` bind **one** key pair to an account per node: `identities` diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index 4e28f65..a793471 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -45,6 +45,10 @@ OP_MEMBER_REVOKE = "member_revoke" OP_GEK_ROTATE = "gek_rotate" # Forgetting a pinned identity, so someone can pair again after losing a device. OP_MEMBER_UNPIN = "member_unpin" +# Turning uploading by ordinary members on or off. Signed like the rest: the +# setting decides who may write to the operator's disk, so a node that took it +# from an unsigned message would let any member re-enable it for everyone. +OP_MEMBER_UPLOAD = "member_upload" # OP_GEK_BUNDLE_STORE is gone. Members no longer hand the node key material at # all: the node holds the GEK and wraps it itself, for a key the recipient proved # they hold (see `join.py` and docs/invite-pairing-v1.md). The operation existed diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index bbdff63..1c6dd3e 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -78,6 +78,8 @@ class MNP: MEMBER_REVOKE_ACK = "member_revoke_ack" MEMBER_UNPIN = "member_unpin" # operator → node: forget an identity MEMBER_UNPIN_ACK = "member_unpin_ack" + MEMBER_UPLOAD = "member_upload" # operator → node: may members upload? + MEMBER_UPLOAD_ACK = "member_upload_ack" # Device linking. A new device files a request bound to a code it displays; # an already-pinned device of the same account approves it. Neither the hub # nor the node can produce the countersignature. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 62bbfa5..0843d1d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -1304,6 +1304,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const [nodeRoots, setNodeRoots] = useState([]); const [isNodeAdmin, setIsNodeAdmin] = useState(false); + // Whether ordinary members may upload here. The node decides and enforces it; + // this only says whether to offer the controls. Defaults to true so a node + // that predates the setting behaves as it always did. + const [memberUpload, setMemberUpload] = useState(true); // Paired ≠ operator account. `is_node_admin` says the hub account owning this // node is the one connecting; this says the node pinned *this browser's* key // as an operator key. Only the second one lets you sign an invite, and only @@ -1392,6 +1396,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, _pendingJoinCode = null; if (cancelled) return; setIsNodeAdmin(!!ack.is_node_admin); + setMemberUpload(ack.member_upload !== false); + // Changed while we are connected, by an operator who may be someone + // else entirely. Without this the button stays until a reconnection, + // and a button that is still there is a button people press. + transport.onUploadPolicy = (allowed) => setMemberUpload(allowed); setOperatorPaired(transport.memberRole === 'operator'); // A first join to this node generated an identity for it; leave it with @@ -1792,6 +1801,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, ? selectedFiles[0] : null; const deletableFiles = selectedFiles.filter( e => isNodeAdmin || (userId && e.uploader_id === userId)); + + // Asked in two places — the Files toolbar and the chat composer — so it is + // answered once. The operator is never locked out of their own node. + const mayUpload = memberUpload || isNodeAdmin; const run = (fn) => { setSelecting(false); setSelected(new Set()); @@ -1963,11 +1976,13 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, ${tab === 'files' && status === 'connected' && html` <div class="file-toolbar"> <div class="toolbar-group"> + ${mayUpload && html` <label class="tb-btn primary"> <${Icon} name="upload" /> ${t('group.upload')} <input type="file" multiple style="display:none" onChange=${uploadFile} /> </label> + `} ${canCreateDir && html` <button class="tb-btn" onClick=${makeDirectory}> <${Icon} name="folder-plus" /> ${t('group.mkdir')} @@ -2085,6 +2100,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, ${tab === 'chat' && status === 'connected' && html` <${ChatPanel} transportRef=${transportRef} username=${username} entries=${entries} gekRef=${gekRef} onRefreshIndex=${refreshIndex} + mayUpload=${mayUpload} onPreview=${(entry) => { if (entry.type === 'video') setVideoEntry(entry); else setPreviewEntry(entry); @@ -2099,6 +2115,8 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, transportRef=${transportRef} gekRef=${gekRef} isNodeAdmin=${isNodeAdmin} userId=${userId} operatorPaired=${operatorPaired} connected=${status === 'connected'} + memberUpload=${memberUpload} + onMemberUpload=${(allowed) => setMemberUpload(allowed)} onLeft=${onLeft} onPaired=${() => setOperatorPaired(true)} /> `} @@ -2272,7 +2290,7 @@ function _b64ToU8(b64) { */ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, isNodeAdmin, userId, operatorPaired, connected, - onPaired, onLeft }) { + memberUpload, onMemberUpload, onPaired, onLeft }) { const [members, setMembers] = useState([]); const [adminId, setAdminId] = useState(''); const [loading, setLoading] = useState(true); @@ -2350,6 +2368,37 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } }, [pairCode, transportRef, userId]); + const [uploadBusy, setUploadBusy] = useState(false); + const [uploadMsg, setUploadMsg] = useState(''); + + /** + * Close or open uploading for everyone who is not the operator. + * + * Signed, like removing a member: the node refuses an unsigned instruction, + * so this is a request to the node rather than a decision taken here. The + * button does not move until the node has said it did it. + */ + const setUploads = useCallback(async (allowed) => { + const transport = transportRef && transportRef.current; + setUploadMsg(''); + setUploadBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.setMemberUpload(allowed, signFn); + if (onMemberUpload) onMemberUpload(allowed); + } catch (err) { + setUploadMsg(err.message); + } finally { + setUploadBusy(false); + } + }, [transportRef, onMemberUpload]); + const [removing, setRemoving] = useState(''); /** @@ -2504,6 +2553,27 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, </div> `} + ${/* Operator only, and only with a live connection: the node is what + holds and enforces this, so there is nothing to show or change + without one. */ isNodeAdmin && 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=${uploadBusy} + onClick=${() => setUploads(!memberUpload)}> + ${uploadBusy ? '...' + : (memberUpload ? t('members.uploads_disable') + : t('members.uploads_enable'))} + </button> + </div> + <p class="settings-hint">${t('members.uploads_hint')}</p> + ${uploadMsg && html`<p class="error-msg">${uploadMsg}</p>`} + </div> + `} + ${connected && html` <div class="settings-section"> <h3 class="settings-heading">${t('device.mine_title')}</h3> @@ -2744,7 +2814,8 @@ function ChatImage({ filename, entries, transportRef, gekRef }) { return html`<img class="chat-att-thumb" src=${blobUrl} alt=${filename} />`; } -function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, onPreview }) { +function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, + onPreview, mayUpload = true }) { const [messages, setMessages] = useState([]); const [hasMore, setHasMore] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); @@ -3041,11 +3112,13 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on </button> `} <div class="chat-input-row"> + ${mayUpload && html` <label class="chat-attach" title="${t('chat.attach')}"> ${attaching ? html`<span class="spinner"></span>` : html`<${Icon} name="clip" />`} <input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} /> </label> + `} <textarea class="chat-input" rows="1" placeholder="${t('chat.placeholder')}" value=${input} 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 ddeaeeb..df72382 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -79,6 +79,12 @@ export default { 'group.tab_members': 'Mitglieder', 'group.tab_settings': "Einstellungen", 'members.danger_leave_hint': "Sie verlieren den Zugriff auf die Dateien und den Chat dieser Gruppe.", + 'members.uploads_title': "Uploads", + 'members.uploads_on': "Mitglieder können Dateien hinzufügen", + 'members.uploads_off': "Nur Sie können Dateien hinzufügen", + 'members.uploads_disable': "Ausschalten", + 'members.uploads_enable': "Einschalten", + 'members.uploads_hint': "Gilt für alle außer Ihnen. Der Knoten lehnt den Upload selbst ab — es geht nicht darum, eine Schaltfläche zu verbergen.", 'members.danger_delete_hint': "Die Gruppe verschwindet für alle Mitglieder. Das lässt sich nicht rückgängig machen.", 'group.filter': 'Dateien filtern …', 'group.col_name': 'Name', 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 9a0f754..ce14f0b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -80,6 +80,12 @@ export default { 'group.tab_members': 'Members', 'group.tab_settings': "Settings", 'members.danger_leave_hint': "You will lose access to this group's files and chat.", + 'members.uploads_title': "Uploads", + 'members.uploads_on': "Members can add files", + 'members.uploads_off': "Only you can add files", + 'members.uploads_disable': "Turn off", + 'members.uploads_enable': "Turn on", + 'members.uploads_hint': "Applies to everyone but you. The node refuses the upload itself, so this is not a matter of hiding a button.", 'members.danger_delete_hint': "This removes the group for every member. It cannot be undone.", 'group.filter': 'Filter files...', 'group.col_name': 'Name', 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 0c72445..2d9084b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -77,6 +77,12 @@ export default { 'group.tab_members': 'Miembros', 'group.tab_settings': "Ajustes", 'members.danger_leave_hint': "Perderá el acceso a los archivos y al chat de este grupo.", + 'members.uploads_title': "Subidas", + 'members.uploads_on': "Los miembros pueden añadir archivos", + 'members.uploads_off': "Solo usted puede añadir archivos", + 'members.uploads_disable': "Desactivar", + 'members.uploads_enable': "Activar", + 'members.uploads_hint': "Se aplica a todos menos a usted. El nodo rechaza la subida por sí mismo: no se trata de ocultar un botón.", 'members.danger_delete_hint': "El grupo desaparece para todos sus miembros. No se puede deshacer.", 'group.filter': 'Filtrar archivos...', 'group.col_name': 'Nombre', 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 0dec5cf..9aa2857 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -78,6 +78,12 @@ export default { 'group.tab_members': 'Membres', 'group.tab_settings': "Paramètres", 'members.danger_leave_hint': "Vous perdrez l’accès aux fichiers et à la discussion de ce groupe.", + 'members.uploads_title': "Envois de fichiers", + 'members.uploads_on': "Les membres peuvent ajouter des fichiers", + 'members.uploads_off': "Vous seul pouvez ajouter des fichiers", + 'members.uploads_disable': "Désactiver", + 'members.uploads_enable': "Activer", + 'members.uploads_hint': "S’applique à tout le monde sauf vous. C’est le nœud qui refuse l’envoi : il ne s’agit pas de masquer un bouton.", 'members.danger_delete_hint': "Le groupe disparaît pour tous ses membres. C’est irréversible.", 'group.filter': 'Filtrer les fichiers...', 'group.col_name': 'Nom', 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 81b6a3a..379e7cb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -78,6 +78,12 @@ export default { 'group.tab_members': 'Membri', 'group.tab_settings': "Impostazioni", 'members.danger_leave_hint': "Perderai l’accesso ai file e alla chat di questo gruppo.", + 'members.uploads_title': "Caricamenti", + 'members.uploads_on': "I membri possono aggiungere file", + 'members.uploads_off': "Solo tu puoi aggiungere file", + 'members.uploads_disable': "Disattiva", + 'members.uploads_enable': "Attiva", + 'members.uploads_hint': "Vale per tutti tranne te. È il nodo a rifiutare il caricamento: non si tratta di nascondere un pulsante.", 'members.danger_delete_hint': "Il gruppo scompare per tutti i membri. Non è reversibile.", 'group.filter': 'Filtra i file...', 'group.col_name': 'Nome', 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 11c6e31..45e7aa5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -76,6 +76,12 @@ export default { 'group.tab_members': 'メンバー', 'group.tab_settings': "設定", 'members.danger_leave_hint': "このグループのファイルとチャットにアクセスできなくなります。", + 'members.uploads_title': "アップロード", + 'members.uploads_on': "メンバーはファイルを追加できます", + 'members.uploads_off': "あなただけがファイルを追加できます", + 'members.uploads_disable': "無効にする", + 'members.uploads_enable': "有効にする", + 'members.uploads_hint': "あなた以外の全員に適用されます。ノード自身がアップロードを拒否するため、ボタンを隠すだけの話ではありません。", 'members.danger_delete_hint': "グループはすべてのメンバーから消えます。元に戻せません。", 'group.filter': 'ファイルを絞り込み…', 'group.col_name': '名前', 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 1af0eb1..37d3e24 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -79,6 +79,12 @@ export default { 'group.tab_members': 'Leden', 'group.tab_settings': "Instellingen", 'members.danger_leave_hint': "U verliest de toegang tot de bestanden en de chat van deze groep.", + 'members.uploads_title': "Uploads", + 'members.uploads_on': "Leden kunnen bestanden toevoegen", + 'members.uploads_off': "Alleen u kunt bestanden toevoegen", + 'members.uploads_disable': "Uitschakelen", + 'members.uploads_enable': "Inschakelen", + 'members.uploads_hint': "Geldt voor iedereen behalve u. De node weigert de upload zelf — het gaat niet om het verbergen van een knop.", 'members.danger_delete_hint': "De groep verdwijnt voor alle leden. Dit kan niet ongedaan worden gemaakt.", 'group.filter': 'Bestanden filteren...', 'group.col_name': 'Naam', 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 f57dc90..6766515 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -83,6 +83,12 @@ export default { 'group.tab_members': 'Członkowie', 'group.tab_settings': "Ustawienia", 'members.danger_leave_hint': "Utracisz dostęp do plików i czatu tej grupy.", + 'members.uploads_title': "Przesyłanie plików", + 'members.uploads_on': "Członkowie mogą dodawać pliki", + 'members.uploads_off': "Tylko Ty możesz dodawać pliki", + 'members.uploads_disable': "Wyłącz", + 'members.uploads_enable': "Włącz", + 'members.uploads_hint': "Dotyczy wszystkich poza Tobą. To węzeł odrzuca przesłanie — nie chodzi o ukrycie przycisku.", 'members.danger_delete_hint': "Grupa zniknie dla wszystkich członków. Tego nie można cofnąć.", 'group.filter': 'Filtruj pliki...', 'group.col_name': 'Nazwa', 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 0f1586f..fa46887 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 @@ -79,6 +79,12 @@ export default { 'group.tab_members': 'Membros', 'group.tab_settings': "Configurações", 'members.danger_leave_hint': "Você perderá o acesso aos arquivos e ao chat deste grupo.", + 'members.uploads_title': "Envios", + 'members.uploads_on': "Os membros podem adicionar arquivos", + 'members.uploads_off': "Somente você pode adicionar arquivos", + 'members.uploads_disable': "Desativar", + 'members.uploads_enable': "Ativar", + 'members.uploads_hint': "Vale para todos menos você. O nó recusa o envio por conta própria: não se trata de esconder um botão.", 'members.danger_delete_hint': "O grupo desaparece para todos os membros. Não há como desfazer.", 'group.filter': 'Filtrar arquivos...', 'group.col_name': 'Nome', 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 89927bb..3276db8 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 @@ -76,6 +76,12 @@ export default { 'group.tab_members': '成员', 'group.tab_settings': "设置", 'members.danger_leave_hint': "您将无法再访问该群组的文件和聊天。", + 'members.uploads_title': "上传", + 'members.uploads_on': "成员可以添加文件", + 'members.uploads_off': "只有您可以添加文件", + 'members.uploads_disable': "关闭", + 'members.uploads_enable': "开启", + 'members.uploads_hint': "适用于除您之外的所有人。节点自身会拒绝上传,并非只是隐藏按钮。", 'members.danger_delete_hint': "该群组将对所有成员消失,且无法恢复。", 'group.filter': '筛选文件…', 'group.col_name': '名称', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 853ae75..a0509a0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -103,6 +103,7 @@ class MeshBayTransport { set onStreamEnd(fn) { this._onStreamEnd = fn; } set onStreamError(fn) { this._onStreamError = fn; } set onIndexSync(fn) { this._onIndexSync = fn; } + set onUploadPolicy(fn) { this._onUploadPolicy = fn; } get sessionKeys() { return this._sessionKeys; } @@ -592,6 +593,24 @@ class MeshBayTransport { * Only the node can do this: its roster decides who it serves. Removing them * on the hub is the other half, and neither implies the other. */ + /** + * Turn uploading by ordinary members on or off. + * + * Signed by the operator like any other privileged operation — the node + * refuses an unsigned one, which is what stops a member turning it back on. + */ + async setMemberUpload(allowed, signFn) { + const msg = await this._sendAndWait({ + type: 'member_upload', v: '0.1', allowed: Boolean(allowed), + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp( + msg, 'member_upload', allowed ? 'on' : 'off', signFn); + } + return msg; + } + async revokeMember(userId, signFn) { const msg = await this._sendAndWait({ type: 'member_revoke', v: '0.1', user_id: userId, @@ -1079,6 +1098,14 @@ class MeshBayTransport { return; } + // The operator changed who may upload. Unsolicited: it arrives at everyone + // connected, not only at whoever asked. It still has to reach a pending + // caller — the operator's own request resolves on this reply — so it falls + // through to the matching below rather than returning here. + if (msg.type === 'member_upload_ack' && this._onUploadPolicy) { + this._onUploadPolicy(Boolean(msg.allowed)); + } + if (msg.type === 'index_sync' && msg.entries) { if (this._onIndexSync) this._onIndexSync(msg); const oldest = this._pending.entries().next(); diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py new file mode 100644 index 0000000..d859125 --- /dev/null +++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py @@ -0,0 +1,122 @@ +""" +When the operator closes uploading, the controls go — both of them. + +There are two ways to put a file into a group and they are in different +components: the Upload button in the Files toolbar, and the paperclip in the +chat composer. Hiding one and forgetting the other is the obvious mistake, and +the second one is the easier to forget because it does not look like an upload. + +Nothing here is a security property. **The node refuses the upload** — that is +`test_member_upload_policy.py` in the node package. This is about not offering +somebody a button whose only outcome is an error message. +""" + +import re +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +APP = STATIC / "app.js" +TRANSPORT = STATIC / "transport.js" + +pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable") + + +@pytest.fixture(scope="module") +def app() -> str: + return APP.read_text(encoding="utf-8") + + +def _component(app: str, name: str) -> str: + start = app.index(f"\nfunction {name}(") + end = app.find("\nfunction ", start + 1) + return app[start:end if end != -1 else len(app)] + + +# ── Both controls ─────────────────────────────────────────────────────────── + +def test_the_files_toolbar_hides_its_upload_button(app): + page = _component(app, "GroupPage") + toolbar = page[page.index("file-toolbar"):] + toolbar = toolbar[:toolbar.index("group.mkdir")] + assert "mayUpload &&" in toolbar, "the Upload button is offered regardless" + + +def test_the_chat_composer_hides_its_paperclip(app): + chat = _component(app, "ChatPanel") + composer = chat[chat.index("chat-input-row"):] + assert "mayUpload &&" in composer, ( + "the chat attachment is the second way in and is still offered") + + +def test_both_read_the_same_answer(app): + """Two derivations would eventually disagree, and the disagreement would + be one of them offering an upload the node refuses.""" + page = _component(app, "GroupPage") + assert re.search(r"const mayUpload = memberUpload \|\| isNodeAdmin;", page), ( + "mayUpload is no longer derived in one place") + assert "mayUpload=${mayUpload}" in page, "the chat panel is told separately" + + +def test_the_operator_keeps_their_own_controls(app): + page = _component(app, "GroupPage") + assert "memberUpload || isNodeAdmin" in page, ( + "turning uploads off would hide the operator's own upload button") + + +# ── Learning the answer ───────────────────────────────────────────────────── + +def test_the_answer_comes_from_the_node(app): + """Not from the hub, which has no say in what may be written to someone + else's disk, and no way to be believed about it.""" + page = _component(app, "GroupPage") + assert "ack.member_upload !== false" in page, ( + "the handshake ack is what carries this") + assert "hubFetch" not in page[page.index("ack.member_upload") - 400: + page.index("ack.member_upload")] + + +def test_an_older_node_is_treated_as_permissive(app): + """A node that predates the setting sends no such field. Reading a missing + field as "off" would close every group on the older half of the network.""" + page = _component(app, "GroupPage") + assert "!== false" in page[page.index("ack.member_upload"): + page.index("ack.member_upload") + 60] + + +def test_a_change_reaches_people_already_connected(app): + """The operator may be someone else entirely, changing it while you have + the group open. A button that survives until the next reconnection is a + button somebody presses.""" + page = _component(app, "GroupPage") + assert "transport.onUploadPolicy" in page + transport = TRANSPORT.read_text(encoding="utf-8") + assert "member_upload_ack" in transport, "nothing routes the node's notice" + + +def test_the_notice_still_answers_the_operators_own_request(app): + """The same message is both a broadcast and the reply to the request that + caused it — returning early on it would leave that request hanging until it + timed out.""" + transport = TRANSPORT.read_text(encoding="utf-8") + block = transport[transport.index("member_upload_ack"):] + block = block[:block.index("index_sync")] + assert "return" not in block + + +# ── Changing it ───────────────────────────────────────────────────────────── + +def test_changing_it_is_signed(app): + transport = TRANSPORT.read_text(encoding="utf-8") + method = transport[transport.index("async setMemberUpload("):] + method = method[:method.index("\n async ", 1)] + assert "admin_challenge" in method and "_authorizeAdminOp" in method, ( + "an unsigned instruction would let any member turn uploads back on") + + +def test_only_the_operator_is_offered_the_setting(app): + panel = _component(app, "GroupSettingsPanel") + section = panel[panel.index("members.uploads_title") - 400: + panel.index("members.uploads_title")] + assert "isNodeAdmin && connected" in section diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index f4dcca5..45aca7a 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -256,6 +256,13 @@ class NodeDaemon: # Admission policy comes from node.toml, never from the hub: # a hub that could declare a group open would be handed its key. "join_policy": group_cfg.join_policy, + # Whether ordinary members may upload. Read once here, into + # the context, because the upload handler is synchronous and + # a database round trip per chunk would be absurd. The + # signed operation that changes it updates this dict in + # place, so the two never drift within a run. + "member_upload": await self._roster.member_upload_allowed( + group_cfg.id) if self._roster else True, } if not groups_ctx: diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 226b784..c811016 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -100,6 +100,26 @@ CREATE TABLE IF NOT EXISTS members ( PRIMARY KEY (group_id, user_id) ); +-- Per-group settings the operator changes while the node runs. +-- +-- Not node.toml: that file is hand-written, full of comments explaining +-- decisions, and `ops.py` deliberately appends to it rather than round-tripping +-- it through a TOML writer. A setting toggled from a panel has to take effect +-- without an edit to the operator's file and without a restart, so it lives +-- here, where the node already keeps what it decided rather than what it was +-- configured with. +-- +-- Absent means default. Nothing writes a row until someone changes something, +-- so an existing node has the same behaviour it had before this table existed. +CREATE TABLE IF NOT EXISTS group_settings ( + group_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + set_by TEXT NOT NULL DEFAULT '', + set_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (group_id, key) +); + CREATE TABLE IF NOT EXISTS invites ( code_hash TEXT PRIMARY KEY, group_id TEXT NOT NULL, @@ -518,6 +538,43 @@ class Roster: # ── Invites ────────────────────────────────────────────────────────────── + # ── Group settings ────────────────────────────────────────────────────── + + # Whether members who are not the operator may upload. Default is yes: a + # group that nobody may add to is the unusual case, and an existing node + # must not change behaviour because a table was added under it. + SETTING_MEMBER_UPLOAD = "member_upload" + + async def get_setting(self, group_id: str, key: str, + default: str | None = None) -> str | None: + async with self._db.execute( + "SELECT value FROM group_settings WHERE group_id = ? AND key = ?", + (group_id, key)) as cur: + row = await cur.fetchone() + return row["value"] if row else default + + async def set_setting(self, group_id: str, key: str, value: str, + set_by: str = "") -> None: + await self._db.execute( + "INSERT INTO group_settings (group_id, key, value, set_by, set_at) " + "VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(group_id, key) DO UPDATE SET " + "value = excluded.value, set_by = excluded.set_by, " + "set_at = excluded.set_at", + (group_id, key, value, set_by, _now())) + await self._db.commit() + + async def member_upload_allowed(self, group_id: str) -> bool: + """Whether an ordinary member may upload to this group.""" + value = await self.get_setting(group_id, self.SETTING_MEMBER_UPLOAD, "1") + return value != "0" + + async def set_member_upload(self, group_id: str, allowed: bool, + set_by: str = "") -> bool: + await self.set_setting(group_id, self.SETTING_MEMBER_UPLOAD, + "1" if allowed else "0", set_by) + return allowed + async def create_invite( self, group_id: str, 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 9d16f82..22e5e15 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -62,6 +62,7 @@ from meshbay_common.adminop import ( OP_MEMBER_REVOKE, OP_GEK_ROTATE, OP_MEMBER_UNPIN, + OP_MEMBER_UPLOAD, admin_transcript, ) from meshbay_common.crypto import pk_to_b64, wrap_gek_aes @@ -469,6 +470,8 @@ class WebRTCPeerSession: self._spawn(self._do_device_list(msg)) elif mtype == MNP.DEVICE_REVOKE: self._spawn(self._do_device_revoke(msg)) + elif mtype == MNP.MEMBER_UPLOAD: + self._do_member_upload(msg) elif mtype == MNP.MEMBER_UNPIN: self._do_member_unpin(msg) elif mtype == MNP.GEK_ROTATE: @@ -687,7 +690,11 @@ class WebRTCPeerSession: "proof": base64.b64encode(node_proof).decode(), "sig": base64.b64encode( self._ctx["sk_node"].sign(node_transcript)).decode(), - "is_node_admin": bool(node_user_id and self._user_id == node_user_id), + "is_node_admin": self._is_node_admin(), + # So the interface knows whether to offer uploading at all. Not a + # permission — the node refuses regardless — but without it the + # only way to discover the answer is to try. + "member_upload": bool(self._group_ctx().get("member_upload", True)), } if node_user_id: ack["node_user_id"] = node_user_id @@ -1568,6 +1575,58 @@ class WebRTCPeerSession: self._send({"type": MNP.MEMBER_UNPIN_ACK, "v": MNP_VERSION, "user_id": user_id}) + def _do_member_upload(self, msg: dict) -> None: + """ + Turn uploading by ordinary members on or off, for this group. + + Signed like every other operator action. The setting decides who may + write to the operator's disk, so a node that took it from an unsigned + message would let any member turn it back on for everyone — the control + would be a suggestion. + """ + if "allowed" not in msg: + self._send({"type": "error", "detail": "Missing allowed"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + # The subject is what the operator is shown before signing, so it has to + # name the outcome rather than the operation. + self._issue_admin_challenge( + OP_MEMBER_UPLOAD, "on" if msg.get("allowed") else "off") + + async def _admin_exec_member_upload( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + allowed = pending["subject"] == "on" + if not await self._verify_admin_sig(transcript, sig): + 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"}) + 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 + # an upload refused. Enforcement does not depend on this reaching them — + # it is the node that refuses — but a button that stays visible until + # the next reconnection is a button people press. + notice = {"type": MNP.MEMBER_UPLOAD_ACK, "v": MNP_VERSION, + "allowed": allowed} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + async def _run_op(self, fn, *args, **kwargs): """ Call an operation from `meshbay_node.ops` with the daemon's own view. @@ -1995,6 +2054,19 @@ class WebRTCPeerSession: "filename": filename}) return + # The operator can close uploading to everyone but themselves. Enforced + # here rather than by hiding a button: the button is a courtesy to the + # people who are not trying, and this is the part that holds against + # someone who is. `is_node_admin` is computed from the identity this + # node pinned, never from a hub claim. + if not ctx.get("member_upload", True) and not self._is_node_admin(): + self._send({"type": "error", + "detail": "Uploading is turned off for this group", + "code": "member_upload_off", + "filename": filename}) + self._audit("upload_refused", filename[:64]) + return + roots: RootSet | None = ctx.get("roots") upload_root = roots.upload_root if roots else None if upload_root is None: @@ -2189,6 +2261,17 @@ class WebRTCPeerSession: if ident: self._pinned_pk = ident["pk_ed25519"] + def _is_node_admin(self) -> bool: + """ + Whether the peer on this connection is the node's operator. + + Was written out twice — once in the handshake ack and once at the gate + below it — which is how the two come to disagree. From the node's own + record of who it belongs to, never from a hub claim. + """ + node_user_id = self._ctx.get("node_user_id") + return bool(node_user_id and self._user_id == node_user_id) + def _has_admin_authority(self) -> bool: """ Cheap synchronous pre-check: is there anyone who could authorize this? @@ -2269,6 +2352,9 @@ class WebRTCPeerSession: elif pending["op"] == OP_MEMBER_UNPIN: self._spawn( self._admin_exec_member_unpin(pending, transcript, sig_bytes)) + elif pending["op"] == OP_MEMBER_UPLOAD: + self._spawn( + self._admin_exec_member_upload(pending, transcript, sig_bytes)) else: self._send({"type": "error", "detail": "Unknown admin operation"}) diff --git a/packages/meshbay-node/tests/test_member_upload_policy.py b/packages/meshbay-node/tests/test_member_upload_policy.py new file mode 100644 index 0000000..b1dc0cb --- /dev/null +++ b/packages/meshbay-node/tests/test_member_upload_policy.py @@ -0,0 +1,176 @@ +""" +The operator can close uploading to everyone but themselves. + +The point of these tests is the difference between a hidden button and a closed +door. The interface stops offering the control, which is a courtesy to the +people who are not trying; **the node refuses the upload**, which is the part +that holds against someone who is. A member who kept an old tab open, or who +speaks MNP directly, gets the same answer as everyone else. + +Two further things are worth holding: + +* the setting is changed by a **signed** operator instruction. A node that took + it from an unsigned message would let any member turn it back on, and the + control would be a suggestion; +* it is stored on the **node**, not the hub. A hub that could decide who may + write to the operator's disk is a hub with authority over the node, which is + the thing this whole design is arranged to avoid. +""" + +import base64 +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_common.adminop import OP_MEMBER_UPLOAD +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import Roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +from conftest import one_root + +pytestmark = pytest.mark.asyncio + + +def _session(tmp_path: Path, user_id: str, *, member_upload: bool, + operator: str | None = None) -> WebRTCPeerSession: + shared_root = tmp_path / "shared" + shared_root.mkdir(exist_ok=True) + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + ctx = { + "roots": one_root(shared_root), + "index": index, + "sk_node": index.sk_node, + "member_upload": member_upload, + "node_user_id": operator, + } + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = ctx + session._group_id = None + session._user_id = user_id + session._pk_user = "" + session._uploads = {} + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +def _upload(session, filename="clip.mp4", body=b"bytes"): + session._do_file_upload({ + "filename": filename, "chunk_index": 0, "total_chunks": 1, + "data": base64.b64encode(body).decode(), + }) + + +def _uploads_dir(session) -> Path: + return session._ctx["roots"].upload_root.path / "uploads" + + +# ── The door, not the button ──────────────────────────────────────────────── + +async def test_a_member_cannot_upload_when_it_is_turned_off(tmp_path): + session = _session(tmp_path, "member-1", member_upload=False, + operator="the-operator") + _upload(session) + + assert not (_uploads_dir(session) / "clip.mp4").exists(), ( + "the file was written even though uploading is off — the setting is " + "decorative and the hidden button was the whole control") + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "member_upload_off" + + +async def test_the_operator_can_still_upload(tmp_path): + """Otherwise turning it off locks the operator out of their own node, and + the only way back is a config file and a restart.""" + session = _session(tmp_path, "the-operator", member_upload=False, + operator="the-operator") + _upload(session) + + assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes" + + +async def test_members_upload_normally_when_it_is_on(tmp_path): + session = _session(tmp_path, "member-1", member_upload=True, + operator="the-operator") + _upload(session) + + assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes" + + +async def test_a_node_that_never_heard_of_the_setting_still_accepts_uploads(tmp_path): + """An existing node's context has no such key. The absence must read as + "allowed", or upgrading the node silently closes every group.""" + session = _session(tmp_path, "member-1", member_upload=True, + operator="the-operator") + del session._ctx["member_upload"] + _upload(session) + + assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes" + + +# ── Who may change it ─────────────────────────────────────────────────────── + +async def test_changing_it_needs_a_signature(tmp_path): + """ + The request only ever produces a challenge. Nothing is applied until a + signature over the transcript verifies — the same path as removing a member. + """ + session = _session(tmp_path, "member-1", member_upload=True, + operator="the-operator") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_member_upload({"allowed": False}) + + assert issued == [(OP_MEMBER_UPLOAD, "off")] + assert session._ctx["member_upload"] is True, "applied before it was signed" + + +async def test_the_subject_names_the_outcome_not_the_operation(tmp_path): + """The operator is shown the subject before signing. "member_upload" tells + them nothing; "off" tells them what they are about to do.""" + session = _session(tmp_path, "op", member_upload=False, operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_member_upload({"allowed": True}) + + assert issued == [(OP_MEMBER_UPLOAD, "on")] + + +async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): + session = _session(tmp_path, "member-1", member_upload=True, + operator="the-operator") + session._has_admin_authority = lambda: False + + session._do_member_upload({"allowed": False}) + + assert [m for m in session.sent if m.get("type") == "error"] + + +# ── Where it is stored ────────────────────────────────────────────────────── + +async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + assert await roster.member_upload_allowed("g1") is True, ( + "absent must mean allowed, or an upgrade closes every group") + await roster.set_member_upload("g1", False, set_by="op") + assert await roster.member_upload_allowed("g1") is False + finally: + await roster.close() + + reopened = Roster(db_path=tmp_path / "roster.db") + await reopened.open() + try: + assert await reopened.member_upload_allowed("g1") is False + assert await reopened.member_upload_allowed("g2") is True, ( + "one group's setting must not answer for another") + finally: + await reopened.close() |