aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md7
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md7
-rw-r--r--docs/USERGUIDE.md3
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt2
-rw-r--r--packages/meshbay-client/src/transcripts.js2
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py6
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py4
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js13
-rw-r--r--packages/meshbay-hub/tests/test_app_settings_plugin.py20
-rw-r--r--packages/meshbay-node/src/meshbay_node/chat/store.py6
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/__init__.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/chat.py19
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py41
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py1
-rw-r--r--packages/meshbay-node/tests/golden/dispatch.json460
-rw-r--r--packages/meshbay-node/tests/test_chat_purge.py110
31 files changed, 713 insertions, 46 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index b874632..c0ccbbc 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -16,7 +16,7 @@
> them — it names the invariant that holds today, not the incident that produced
> it. §13 is the register of those labels.
>
-> Wire versions at the time of writing: **MNP 6.0** (oldest peer accepted 4.0),
+> Wire versions at the time of writing: **MNP 6.1** (oldest peer accepted 4.0),
> **MHP 0.1**, packages **0.19.0**. The normative source for the wire format is
> `MESHBAY_NODE_PROTOCOL.md`; this document states the design the protocol
> serves, not its byte layout.
@@ -1838,7 +1838,10 @@ chat opens at the newest page. A forwards pager is not what a chat opens with.
`meshbay-node chat prune <days>` deletes **messages only, never an epoch key**. An
epoch with no messages is harmless; an epoch key deleted while messages still need
-it is an unreadable archive.
+it is an unreadable archive. The operator's purge (the signed `chat_purge`, from
+the Chat settings) is the same rule with no age: every message goes, the epoch
+keys and the attachments stay. The replay index goes with the rows, which costs
+nothing, since a sealed message is taken only from the device that signed it.
**A message is bounded in size and in rate, like every other member-supplied
write.** Sending one costs the operator a row that nothing expires, every other
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index 0045f78..1355aaa 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -1173,6 +1173,7 @@ broadcast, every connected peer in the group learns the change without reconnect
| `chat_link_preview` | `on\|off` | operator | `chat_link_preview_ack{enabled}` | yes |
| `search_listed` | `on\|off` | operator | `search_listed_ack{listed}` | yes |
| `chat_epoch` | `group_id` | operator | `chat_epoch_ack{epoch}` | yes |
+| `chat_purge` | `group_id`, always the connection's group | operator | `chat_purge_ack{removed}` | yes: every open chat panel empties |
**Upload policy is not in this table**, and that is the design: whether a member may
write is a property of each root (its `writable` flag), not a switch over the group. A single
@@ -2126,6 +2127,7 @@ it back (§3.5).
| `client_diag` | C→N | auth | the video player's own view of a stream, written to the node's log beside its own (a stream event at INFO, the periodic state at DEBUG); the node acts on none of it and sends no reply |
| `member_revoke` / `_ack` | C→N / N→C | signed | stop serving the key to someone |
| `chat_epoch` / `_ack` | C→N / N⇒C | signed | open a new chat epoch by hand |
+| `chat_purge` / `_ack` | C→N / N⇒C | signed | delete the group's stored chat; epoch keys and attachments stay (6.1) |
| `app_directories` / `_ack` | C→N / N⇒C | signed | one application's folders, keyed by app name |
| `chat_directory` / `_ack` | C→N / N⇒C | signed | where chat attachments are written |
| `chat_link_preview` / `_ack` | C→N / N⇒C | signed | whether the node unfurls posted links |
@@ -2175,7 +2177,7 @@ message:
## 13. Versioning and compatibility
-MNP versions independently of the package version. Current: **`6.0`**; oldest peer
+MNP versions independently of the package version. Current: **`6.1`**; oldest peer
accepted: **`4.0`**.
4.0 is the floor: a member presents a short-lived node-audience token bound to one node
@@ -2198,6 +2200,9 @@ ten operator messages no client ever sent (§10.4, "The node's own controls"). T
also refuses to sign them, so a node older than 6.0 cannot be driven into them by a
script in its page either.
+6.1 adds the signed `chat_purge` (§10.4), additively: a 6.0 node gives no
+answer, as for any unknown type, and nothing else changes.
+
The two numbers are separate on purpose. `MNP_VERSION` says what this build speaks;
`MNP_MIN_SUPPORTED` says what it will talk to, and moving the second is a decision about
whether an older peer can still do anything useful:
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index eb659e1..9db9748 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -323,6 +323,9 @@ previews.
off.
- **History is kept on the node**, and stays readable to members. A member
removed from the group cannot read anything written after their removal.
+- The operator can **purge the chat** from the group's Settings, Chat
+ section: every message goes, for everyone, and cannot be brought back.
+ Attachments stay in their folder.
- Sometimes a message reads *"Written before this device could read this
conversation"* — that is a device added later, not an error. A message marked
*"the signature does not match the sender"* is different and worth asking
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt
index cf20a0b..a48c4cf 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt
@@ -172,7 +172,7 @@ class Transcripts(private val now: () -> Double = { System.currentTimeMillis() /
"tmdb_config", "tmdb_enabled", "tmdb_override", "tmdb_rematch",
"musicbrainz_enabled", "root_remove", "root_eject", "root_plug",
"app_directories", "chat_directory", "chat_link_preview", "search_listed",
- "chat_epoch",
+ "chat_epoch", "chat_purge",
)
const val PREFIX_JOIN = "meshbay:join:v1"
const val PREFIX_DEVICE_REQUEST = "meshbay:device_req:v1"
diff --git a/packages/meshbay-client/src/transcripts.js b/packages/meshbay-client/src/transcripts.js
index a58cb24..ffba3bf 100644
--- a/packages/meshbay-client/src/transcripts.js
+++ b/packages/meshbay-client/src/transcripts.js
@@ -43,7 +43,7 @@ const ADMIN_OPS = new Set([
'tmdb_config', 'tmdb_enabled', 'tmdb_override', 'tmdb_rematch',
'musicbrainz_enabled', 'root_remove', 'root_eject', 'root_plug',
'app_directories', 'chat_directory', 'chat_link_preview', 'search_listed',
- 'chat_epoch',
+ 'chat_epoch', 'chat_purge',
]);
// ── Field checks ──────────────────────────────────────────────────────────
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index dac5871..e8a9579 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -272,5 +272,9 @@ __version__ = "0.19.0"
# `node_status`, `node_settings_set`, `roster_read`, `denylist_read`,
# `denylist_clear`, `node_reload` — whose work the Node page and the CLI do over
# loopback.
-MNP_VERSION = "6.0"
+#
+# 6.1 (2026-10-08) adds the signed `chat_purge` / `chat_purge_ack`: the operator
+# deletes a group's stored chat. Additive — a 6.0 node answers nothing, as for
+# any unknown type, and nothing else changes. The floor stays at 4.0.
+MNP_VERSION = "6.1"
MHP_VERSION = "0.1"
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index bd7a439..f59b420 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -107,6 +107,10 @@ OP_SEARCH_LISTED = "search_listed"
# There is no op for *enabling* chat encryption. It is not a setting — MNP 2.0
# has no plaintext chat to fall back to.
OP_CHAT_EPOCH = "chat_epoch"
+# Delete every stored message of a group's chat (MNP 6.1). The subject is the
+# group id, like chat_epoch. Epoch keys and attachments stay: an attachment is a
+# file in a shared folder, and removing it is the Files app's business.
+OP_CHAT_PURGE = "chat_purge"
OP_ROOT_EJECT = "root_eject"
OP_ROOT_PLUG = "root_plug"
# Also gone with 6.0, because no client ever sent them: `gek_rotate`,
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index c048b8d..5073b66 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -244,6 +244,10 @@ class MNP:
# key without having to reconnect.
CHAT_EPOCH = "chat_epoch"
CHAT_EPOCH_ACK = "chat_epoch_ack"
+ # Operator → node: delete the group's stored chat. The ack is broadcast so
+ # every open chat panel empties without reloading (MNP 6.1).
+ CHAT_PURGE = "chat_purge"
+ CHAT_PURGE_ACK = "chat_purge_ack"
ROOT_EJECT = "root_eject" # operator → node: mark removable root as ejected
ROOT_EJECT_ACK = "root_eject_ack"
ROOT_PLUG = "root_plug" # operator → node: re-enable an ejected root
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js
index 1eba59a..892c332 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js
@@ -73,6 +73,13 @@ function ChatSettings({ roots, dirs, settings, saveDirectories, transport,
</button>
<p class="settings-hint">${t('settings_app.chat_rotate_epoch_hint')}</p>
</div>
+ <div class="settings-row" style="margin-top:12px">
+ <button class="app-save" disabled=${busy}
+ onClick=${() => run(() => transport.purgeChat(signFn))}>
+ ${busy ? t('settings_app.saving') : t('settings_app.chat_purge')}
+ </button>
+ <p class="settings-hint">${t('settings_app.chat_purge_hint')}</p>
+ </div>
${msg && html`<p class="settings-hint">${msg}</p>`}
</div>
`;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
index 0057218..1d61c43 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
@@ -278,7 +278,13 @@ function ChatPanel({ transportRef, username, userId, entries, gekRef,
if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id);
};
- return () => { transport.onChat = null; };
+ transport.onChatPurged = () => {
+ setMessages([]);
+ setHasMore(false);
+ setUnreadFrom(null);
+ };
+
+ return () => { transport.onChat = null; transport.onChatPurged = null; };
}, [status]);
const loadOlder = useCallback(async () => {
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 d8b7b5c..768709e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -1099,6 +1099,8 @@ export default {
'settings_app.chat_encrypted_always': 'Der Chat dieser Gruppe ist immer verschlüsselt. Das lässt sich nicht abschalten.',
'settings_app.chat_rotate_epoch': 'Chat-Schlüssel weiterdrehen',
'settings_app.chat_rotate_epoch_hint': 'Mitglieder lesen weiterhin den gesamten Verlauf. Wer aus der Gruppe entfernt wurde, kann nicht mehr lesen, was ab jetzt geschrieben wird. Ein Mitglied oder Gerät zu entfernen tut das bereits von selbst.',
+ 'settings_app.chat_purge': 'Chat leeren',
+ 'settings_app.chat_purge_hint': 'Löscht alle Nachrichten im Chat dieser Gruppe auf diesem Knoten, für alle Mitglieder. Anhänge bleiben in ihrem Ordner. Das lässt sich nicht rückgängig machen.',
'settings_app.tmdb_token_prompt': 'Registrieren Sie sich bei TMDB, um einen eigenen API-Schlüssel zu erzeugen.',
'settings_app.tmdb_token_link': 'Schlüssel holen',
'settings_node.roots_offline_hint': 'Nicht mit dem Node verbunden — Änderungen laufen über den lokalen Node und greifen beim nächsten Neuladen.',
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 a4a8215..de7fc91 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -799,6 +799,8 @@ export default {
'settings_app.chat_encrypted_always': 'Chat in this group is always encrypted. It cannot be turned off.',
'settings_app.chat_rotate_epoch': 'Move the chat key on',
'settings_app.chat_rotate_epoch_hint': 'Members keep reading the whole history. Anyone removed from the group cannot read what is written from now on. Removing a member or a device already does this by itself.',
+ 'settings_app.chat_purge': 'Purge the chat',
+ 'settings_app.chat_purge_hint': 'Deletes every message in this group\'s chat on this node, for every member. Attachments stay in their folder. This cannot be undone.',
'settings_app.tmdb_token_prompt': 'Sign up on TMDB to generate your own API key.',
'settings_app.tmdb_token_link': 'Get a key',
'settings_node.roots_offline_hint': 'Not connected to the node — changes go through the local node instead, and take effect on its next reload.',
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 78160d4..b8ca4c4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -1093,6 +1093,8 @@ export default {
'settings_app.chat_encrypted_always': 'El chat de este grupo siempre está cifrado. No se puede desactivar.',
'settings_app.chat_rotate_epoch': 'Renovar la clave del chat',
'settings_app.chat_rotate_epoch_hint': 'Los miembros siguen leyendo todo el historial. Quien haya sido expulsado del grupo no podrá leer lo que se escriba a partir de ahora. Quitar a un miembro o un dispositivo ya lo hace por sí solo.',
+ 'settings_app.chat_purge': 'Vaciar el chat',
+ 'settings_app.chat_purge_hint': 'Borra todos los mensajes del chat de este grupo en este nodo, para todos los miembros. Los adjuntos se quedan en su carpeta. No se puede deshacer.',
'settings_app.tmdb_token_prompt': 'Regístrate en TMDB para generar tu propia clave de API.',
'settings_app.tmdb_token_link': 'Obtener una clave',
'settings_node.roots_offline_hint': 'Sin conexión con el nodo: los cambios pasan por el nodo local y se aplican en su próxima recarga.',
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 1fbb6cf..3a94f13 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -1111,6 +1111,8 @@ export default {
'settings_app.chat_encrypted_always': 'La discussion de ce groupe est toujours chiffrée. Cela ne peut pas être désactivé.',
'settings_app.chat_rotate_epoch': 'Faire tourner la clé de discussion',
'settings_app.chat_rotate_epoch_hint': 'Les membres continuent de lire tout l\'historique. Quiconque a été retiré du groupe ne peut plus lire ce qui sera écrit. Retirer un membre ou un appareil le fait déjà tout seul.',
+ 'settings_app.chat_purge': 'Purger la discussion',
+ 'settings_app.chat_purge_hint': 'Supprime tous les messages de la discussion de ce groupe sur ce nœud, pour tous les membres. Les pièces jointes restent dans leur dossier. C\'est irréversible.',
'settings_app.tmdb_token_prompt': 'Créez un compte TMDB pour générer votre propre clé d\'API.',
'settings_app.tmdb_token_link': 'Obtenir une clé',
'settings_node.roots_offline_hint': 'Non connecté au nœud — les changements passent par le nœud local et prennent effet à son prochain rechargement.',
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 250e769..67fe6d3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -1107,6 +1107,8 @@ export default {
'settings_app.chat_encrypted_always': 'La chat di questo gruppo è sempre cifrata. Non può essere disattivata.',
'settings_app.chat_rotate_epoch': 'Ruota la chiave della chat',
'settings_app.chat_rotate_epoch_hint': 'I membri continuano a leggere tutta la cronologia. Chi è stato rimosso dal gruppo non potrà leggere ciò che verrà scritto d\'ora in poi. Rimuovere un membro o un dispositivo lo fa già da solo.',
+ 'settings_app.chat_purge': 'Svuota la chat',
+ 'settings_app.chat_purge_hint': 'Elimina tutti i messaggi della chat di questo gruppo su questo nodo, per tutti i membri. Gli allegati restano nella loro cartella. Non si può annullare.',
'settings_app.tmdb_token_prompt': 'Registrati su TMDB per generare la tua chiave API.',
'settings_app.tmdb_token_link': 'Ottieni una chiave',
'settings_node.roots_offline_hint': 'Non connesso al nodo: le modifiche passano dal nodo locale e hanno effetto al successivo ricaricamento.',
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 ad4ba25..019cc86 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -1091,6 +1091,8 @@ export default {
'settings_app.chat_encrypted_always': 'このグループのチャットは常に暗号化されています。無効にはできません。',
'settings_app.chat_rotate_epoch': 'チャット鍵を更新する',
'settings_app.chat_rotate_epoch_hint': 'メンバーは履歴全体を引き続き読めます。グループから外された相手は、これ以降に書かれた内容を読めません。メンバーや端末を削除すると、これは自動的に行われます。',
+ 'settings_app.chat_purge': 'チャットを消去する',
+ 'settings_app.chat_purge_hint': 'このノードに保存されたこのグループのチャットのメッセージを、全メンバー分すべて削除します。添付ファイルはフォルダーに残ります。元に戻せません。',
'settings_app.tmdb_token_prompt': 'TMDB に登録して、自分の API キーを発行してください。',
'settings_app.tmdb_token_link': 'キーを取得',
'settings_node.roots_offline_hint': 'ノードに接続していません — 変更はローカルノード経由で行われ、次回の再読み込みで反映されます。',
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 78bada9..eb96496 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -1109,6 +1109,8 @@ export default {
'settings_app.chat_encrypted_always': 'De chat van deze groep is altijd versleuteld. Dit kan niet worden uitgezet.',
'settings_app.chat_rotate_epoch': 'Chatsleutel doordraaien',
'settings_app.chat_rotate_epoch_hint': 'Leden blijven de hele geschiedenis lezen. Wie uit de groep is verwijderd, kan niet lezen wat er vanaf nu wordt geschreven. Een lid of apparaat verwijderen doet dit al vanzelf.',
+ 'settings_app.chat_purge': 'Chat leegmaken',
+ 'settings_app.chat_purge_hint': 'Verwijdert alle berichten in de chat van deze groep op deze node, voor alle leden. Bijlagen blijven in hun map. Dit kan niet ongedaan worden gemaakt.',
'settings_app.tmdb_token_prompt': 'Meld u aan bij TMDB om uw eigen API-sleutel te maken.',
'settings_app.tmdb_token_link': 'Sleutel ophalen',
'settings_node.roots_offline_hint': 'Niet verbonden met de node — wijzigingen gaan via de lokale node en worden bij de volgende herlaadbeurt actief.',
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 fd26974..61cce3d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -1135,6 +1135,8 @@ export default {
'settings_app.chat_encrypted_always': 'Czat w tej grupie jest zawsze szyfrowany. Nie da się tego wyłączyć.',
'settings_app.chat_rotate_epoch': 'Zmień klucz czatu',
'settings_app.chat_rotate_epoch_hint': 'Członkowie nadal czytają całą historię. Osoba usunięta z grupy nie odczyta tego, co zostanie napisane od teraz. Usunięcie członka lub urządzenia robi to już samo.',
+ 'settings_app.chat_purge': 'Wyczyść czat',
+ 'settings_app.chat_purge_hint': 'Usuwa wszystkie wiadomości czatu tej grupy na tym węźle, dla wszystkich członków. Załączniki zostają w swoim folderze. Tego nie można cofnąć.',
'settings_app.tmdb_token_prompt': 'Zarejestruj się w TMDB, aby wygenerować własny klucz API.',
'settings_app.tmdb_token_link': 'Pobierz klucz',
'settings_node.roots_offline_hint': 'Brak połączenia z węzłem — zmiany przechodzą przez węzeł lokalny i zaczną działać po jego następnym przeładowaniu.',
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 e91ab10..6279f46 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
@@ -1094,6 +1094,8 @@ export default {
'settings_app.chat_encrypted_always': 'O chat deste grupo é sempre criptografado. Não é possível desativar.',
'settings_app.chat_rotate_epoch': 'Girar a chave do chat',
'settings_app.chat_rotate_epoch_hint': 'Os membros continuam lendo todo o histórico. Quem foi removido do grupo não conseguirá ler o que for escrito daqui em diante. Remover um membro ou dispositivo já faz isso sozinho.',
+ 'settings_app.chat_purge': 'Limpar o chat',
+ 'settings_app.chat_purge_hint': 'Apaga todas as mensagens do chat deste grupo neste nó, para todos os membros. Os anexos ficam na pasta deles. Não é possível desfazer.',
'settings_app.tmdb_token_prompt': 'Cadastre-se no TMDB para gerar sua própria chave de API.',
'settings_app.tmdb_token_link': 'Obter uma chave',
'settings_node.roots_offline_hint': 'Sem conexão com o nó — as alterações passam pelo nó local e entram em vigor no próximo recarregamento.',
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 c030523..02869c5 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
@@ -1079,6 +1079,8 @@ export default {
'settings_app.chat_encrypted_always': '本群组的聊天始终加密,无法关闭。',
'settings_app.chat_rotate_epoch': '更换聊天密钥',
'settings_app.chat_rotate_epoch_hint': '成员仍可阅读全部历史记录。已被移出群组的人无法阅读此后写入的内容。移除成员或设备时已自动执行此操作。',
+ 'settings_app.chat_purge': '清空聊天',
+ 'settings_app.chat_purge_hint': '删除此节点上该群组聊天的全部消息,对所有成员生效。附件保留在其文件夹中。此操作无法撤销。',
'settings_app.tmdb_token_prompt': '在 TMDB 注册以生成你自己的 API 密钥。',
'settings_app.tmdb_token_link': '获取密钥',
'settings_node.roots_offline_hint': '未连接到节点 — 变更将通过本地节点进行,并在其下次重新加载时生效。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
index 96e06d7..9ce07bc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
@@ -78,6 +78,21 @@ extendTransport(class {
}
/**
+ * Delete every stored message of this group's chat. Operator only, and
+ * signed; the subject is the group id. The node broadcasts `chat_purge_ack`,
+ * which empties every open chat panel, this one included.
+ */
+ async purgeChat(signFn) {
+ const groupId = this._groupId || '';
+ const msg = await this._sendAndWait({ type: 'chat_purge', v: '6.1' });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'chat_purge', groupId, signFn);
+ }
+ return msg;
+ }
+
+ /**
* A page of chat history, newest first by default.
*
* `before` is a message id, not a timestamp: it pages backwards from the
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index fa5d28b..e995ffe 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -197,7 +197,7 @@ function _aborted() {
const BROADCAST_ACK_TYPES = new Set([
'root_eject_ack', 'root_plug_ack', 'root_remove_ack',
'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack',
- 'chat_epoch_ack', 'search_listed_ack',
+ 'chat_epoch_ack', 'chat_purge_ack', 'search_listed_ack',
]);
/** Hand a broadcast ack to the callback that would have had it from a peer. */
@@ -212,6 +212,8 @@ function _replayBroadcast(transport, msg) {
transport._onSearchListed(msg.listed !== false);
} else if (msg.type === 'chat_epoch_ack') {
transport._applyChatEpoch(msg);
+ } else if (msg.type === 'chat_purge_ack') {
+ if (transport._onChatPurged) transport._onChatPurged();
} else if (transport._onRootsChanged) {
transport._onRootsChanged(msg);
}
@@ -223,7 +225,7 @@ const ADMIN_OP_TYPES = new Set([
'apps_enabled', 'set_scan_settings', 'member_revoke',
'root_remove', 'root_eject', 'root_plug',
'app_directories', 'chat_directory', 'chat_link_preview', 'chat_epoch',
- 'search_listed', 'invite_create', 'invite_link_create', 'invite_cancel',
+ 'chat_purge', 'search_listed', 'invite_create', 'invite_link_create', 'invite_cancel',
]);
// ── Diagnostic trace (opt-in, off by default) ───────────────────────────────
@@ -545,6 +547,7 @@ class MeshBayTransport {
this._recvBuf = new Uint8Array(0);
this._connected = false;
this._onChat = null;
+ this._onChatPurged = null;
this._onStreamInit = null;
this._onStreamData = null;
this._onStreamEnd = null;
@@ -625,6 +628,7 @@ class MeshBayTransport {
get reconnecting() { return !!this._reconnectPromise; }
set onChat(fn) { this._onChat = fn; }
+ set onChatPurged(fn) { this._onChatPurged = fn; }
set onStreamInit(fn) { this._onStreamInit = fn; }
set onStreamData(fn) { this._onStreamData = fn; }
set onStreamEnd(fn) { this._onStreamEnd = fn; }
@@ -1996,6 +2000,11 @@ class MeshBayTransport {
this._applyChatEpoch(msg);
return;
}
+ // The operator purged the group's chat: every open panel empties.
+ if (msg.type === 'chat_purge_ack') {
+ if (this._onChatPurged) this._onChatPurged();
+ return;
+ }
// Node-wide (not per-group) — the operator supplied/cleared a custom
// token, or changed the query language. `token_customized` only says
diff --git a/packages/meshbay-hub/tests/test_app_settings_plugin.py b/packages/meshbay-hub/tests/test_app_settings_plugin.py
index a56d70f..d469d8a 100644
--- a/packages/meshbay-hub/tests/test_app_settings_plugin.py
+++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py
@@ -391,3 +391,23 @@ def test_a_saved_setting_reaches_the_page_that_renders_the_pane():
replay = replay[:replay.index("\n}") + 2]
for cb in ("_onChatDirectory", "_onChatLinkPreview", "_onAppDirectories"):
assert cb in replay, f"{cb} is never called for the requester"
+
+
+def test_a_purge_empties_the_operators_own_chat_too():
+ """The same trap for `chat_purge`: the operator who purged is the one whose
+ request would swallow the broadcast, and their chat would stay full."""
+ transport = transport_source()
+ block = transport[transport.index("const BROADCAST_ACK_TYPES"):]
+ assert "'chat_purge_ack'" in block[:block.index("]);")]
+ replay = transport[transport.index("function _replayBroadcast"):]
+ replay = replay[:replay.index("\n}") + 2]
+ assert "_onChatPurged" in replay
+
+ chat = (STATIC / "chat-app.js").read_text(encoding="utf-8")
+ hook = chat[chat.index("transport.onChatPurged = "):]
+ hook = hook[:hook.index("};")]
+ assert "setMessages([])" in hook and "setHasMore(false)" in hook
+ assert "transport.onChatPurged = null" in chat, "a closed panel keeps the hook"
+
+ pane = (STATIC / "chat-app-settings.js").read_text(encoding="utf-8")
+ assert "transport.purgeChat(signFn)" in pane
diff --git a/packages/meshbay-node/src/meshbay_node/chat/store.py b/packages/meshbay-node/src/meshbay_node/chat/store.py
index 17cd68e..9e07aff 100644
--- a/packages/meshbay-node/src/meshbay_node/chat/store.py
+++ b/packages/meshbay-node/src/meshbay_node/chat/store.py
@@ -289,6 +289,12 @@ class ChatStore:
await self._db.commit()
return cursor.rowcount
+ async def delete_all(self) -> int:
+ """The operator's purge. Returns how many rows went."""
+ cursor = await self._db.execute("DELETE FROM messages")
+ await self._db.commit()
+ return cursor.rowcount
+
async def message_count(self) -> int:
cursor = await self._db.execute("SELECT COUNT(*) FROM messages")
row = await cursor.fetchone()
diff --git a/packages/meshbay-node/src/meshbay_node/ops/__init__.py b/packages/meshbay-node/src/meshbay_node/ops/__init__.py
index bfdfd7b..fc5b7ef 100644
--- a/packages/meshbay-node/src/meshbay_node/ops/__init__.py
+++ b/packages/meshbay-node/src/meshbay_node/ops/__init__.py
@@ -41,6 +41,7 @@ from meshbay_node.ops.chat import (
ensure_chat_epoch,
open_chat_epoch,
prune_chat,
+ purge_chat,
)
from meshbay_node.ops.core import (
OpError,
@@ -140,6 +141,7 @@ __all__ = [
"pair_operator",
"plug_root",
"prune_chat",
+ "purge_chat",
"prune_index_cache",
"read_denylist",
"read_roster",
diff --git a/packages/meshbay-node/src/meshbay_node/ops/chat.py b/packages/meshbay-node/src/meshbay_node/ops/chat.py
index 539284b..9fc7b94 100644
--- a/packages/meshbay-node/src/meshbay_node/ops/chat.py
+++ b/packages/meshbay-node/src/meshbay_node/ops/chat.py
@@ -252,3 +252,22 @@ async def prune_chat(state: dict, group_id: str, max_age_days: int) -> dict:
group_id[:8], removed)
return {"group_id": group_id, "removed": removed,
"max_age_days": max_age_days}
+
+
+async def purge_chat(state: dict, group_id: str) -> dict:
+ """
+ Delete every stored message of the group. Epoch keys are kept, as in
+ `prune_chat`, and attachments too: they are files in a shared folder.
+
+ The replay index goes with the rows, which is harmless: the node takes a
+ sealed message only from the device that signed it, so the one party able
+ to send a purged message again is its author.
+ """
+ ctx = _group_ctx(state, group_id)
+ store = ctx.get("chat_store")
+ if store is None:
+ raise OpError("This group has no chat store", status=404)
+ removed = await store.delete_all()
+ log.info("Chat purged for group %s: %d message(s) removed",
+ group_id[:8], removed)
+ return {"group_id": group_id, "removed": removed}
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
index 48734ab..7b3b1c9 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
@@ -15,6 +15,7 @@ from meshbay_common.adminop import (
OP_CHAT_DIRECTORY,
OP_CHAT_EPOCH,
OP_CHAT_LINK_PREVIEW,
+ OP_CHAT_PURGE,
OP_DIR_DELETE,
OP_FILE_DELETE,
OP_INVITE_CANCEL,
@@ -68,6 +69,7 @@ _ADMIN_EXECUTORS = {
OP_CHAT_LINK_PREVIEW: "_admin_exec_chat_link_preview",
OP_SEARCH_LISTED: "_admin_exec_search_listed",
OP_CHAT_EPOCH: "_admin_exec_chat_epoch",
+ OP_CHAT_PURGE: "_admin_exec_chat_purge",
OP_ROOT_EJECT: "_admin_exec_root_eject",
OP_ROOT_PLUG: "_admin_exec_root_plug",
}
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
index dc43ae2..f200884 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
@@ -7,7 +7,12 @@ import time
import blake3
from meshbay_common import MNP_VERSION
-from meshbay_common.adminop import OP_CHAT_DIRECTORY, OP_CHAT_EPOCH, OP_CHAT_LINK_PREVIEW
+from meshbay_common.adminop import (
+ OP_CHAT_DIRECTORY,
+ OP_CHAT_EPOCH,
+ OP_CHAT_LINK_PREVIEW,
+ OP_CHAT_PURGE,
+)
from meshbay_common.chatbox import NONCE_LEN as CHAT_NONCE_LEN
from meshbay_common.chatbox import SIG_LEN as CHAT_SIG_LEN
from meshbay_common.groupbox import PURPOSE_CHAT_KEYS, seal
@@ -227,6 +232,40 @@ class ChatMixin:
self._broadcast_to_group({"type": MNP.CHAT_EPOCH_ACK,
"v": MNP_VERSION, "epoch": result["epoch"]})
+ def _do_chat_purge(self, msg: dict) -> None:
+ """
+ Delete the group's stored chat. Operator only, and signed. The subject is
+ the group id, so a signature for one group's purge purges no other.
+
+ Always this connection's group, never one named in the message: the ack
+ is broadcast to this group, and an operator purges the chat they see.
+ """
+ group_id = self._group_id
+ if not group_id:
+ self._send({"type": "error", "detail": "No group on this connection"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(OP_CHAT_PURGE, group_id, group_id=group_id)
+
+ async def _admin_exec_chat_purge(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"chat_purge:{pending['subject'][:8]}")
+ return
+ try:
+ result = await self._run_op(ops.purge_chat, pending["subject"])
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("chat_purge", str(result["removed"]))
+ # Every open chat panel empties, not only the operator's.
+ self._broadcast_to_group({"type": MNP.CHAT_PURGE_ACK,
+ "v": MNP_VERSION, "removed": result["removed"]})
+
async def _do_chat_keys_req(self, msg: dict) -> None:
"""
Hand this member every chat epoch key the group has, sealed.
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
index a8b5767..5d4ed94 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
@@ -56,6 +56,7 @@ _HANDLERS = {
MNP.CHAT_LINK_PREVIEW: ("_do_chat_link_preview", INLINE),
MNP.SEARCH_LISTED: ("_do_search_listed", INLINE),
MNP.CHAT_EPOCH: ("_do_chat_epoch", INLINE),
+ MNP.CHAT_PURGE: ("_do_chat_purge", INLINE),
MNP.CHAT_KEYS_REQ: ("_do_chat_keys_req", SPAWNED),
MNP.GROUP_ROSTER_REQ: ("_do_group_roster_req", SPAWNED),
MNP.MEDIA_META_REQ: ("_do_media_meta_request", SPAWNED),
diff --git a/packages/meshbay-node/tests/golden/dispatch.json b/packages/meshbay-node/tests/golden/dispatch.json
index 44d030a..a39c57d 100644
--- a/packages/meshbay-node/tests/golden/dispatch.json
+++ b/packages/meshbay-node/tests/golden/dispatch.json
@@ -60,6 +60,14 @@
"_admin_exec_chat_link_preview"
]
},
+ "chat_purge": {
+ "audit": [],
+ "log": [],
+ "sent": [],
+ "spawned": [
+ "_admin_exec_chat_purge"
+ ]
+ },
"dir_delete": {
"audit": [],
"log": [],
@@ -2012,7 +2020,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2335,7 +2343,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2354,7 +2362,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2373,7 +2381,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2392,7 +2400,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2662,7 +2670,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2676,7 +2684,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2690,7 +2698,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2704,7 +2712,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2718,7 +2726,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2732,7 +2740,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2746,7 +2754,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -2760,7 +2768,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -3817,6 +3825,386 @@
],
"spawned": []
},
+ "chat_purge | challenged | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | challenged | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | challenged | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | challenged | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | fresh | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | fresh | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | fresh | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | fresh | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | member | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "No authorized key for this",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | member | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "No authorized key for this",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | member | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "No authorized key for this",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | member | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "No authorized key for this",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | operator | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "group_id": "gggggggggggggggggggggggggggggggg",
+ "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
+ "nonce": "<volatile>",
+ "op": "chat_purge",
+ "op_id": "<volatile>",
+ "req_id": 4242,
+ "subject": "gggggggggggggggggggggggggggggggg",
+ "ts": "<volatile>",
+ "type": "admin_challenge",
+ "v": "6.1"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | operator | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "group_id": "gggggggggggggggggggggggggggggggg",
+ "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
+ "nonce": "<volatile>",
+ "op": "chat_purge",
+ "op_id": "<volatile>",
+ "req_id": 4242,
+ "subject": "gggggggggggggggggggggggggggggggg",
+ "ts": "<volatile>",
+ "type": "admin_challenge",
+ "v": "6.1"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | operator | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "group_id": "gggggggggggggggggggggggggggggggg",
+ "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
+ "nonce": "<volatile>",
+ "op": "chat_purge",
+ "op_id": "<volatile>",
+ "req_id": 4242,
+ "subject": "gggggggggggggggggggggggggggggggg",
+ "ts": "<volatile>",
+ "type": "admin_challenge",
+ "v": "6.1"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge | operator | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "group_id": "gggggggggggggggggggggggggggggggg",
+ "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
+ "nonce": "<volatile>",
+ "op": "chat_purge",
+ "op_id": "<volatile>",
+ "req_id": 4242,
+ "subject": "gggggggggggggggggggggggggggggggg",
+ "ts": "<volatile>",
+ "type": "admin_challenge",
+ "v": "6.1"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | challenged | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | challenged | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | challenged | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | challenged | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | fresh | bare": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | fresh | lists": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | fresh | numbers": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | fresh | strings": {
+ "audit": [],
+ "log": [],
+ "sent": [
+ {
+ "detail": "Handshake required",
+ "req_id": 4242,
+ "type": "error"
+ }
+ ],
+ "spawned": []
+ },
+ "chat_purge_ack | member | bare": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "chat_purge_ack | member | lists": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "chat_purge_ack | member | numbers": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "chat_purge_ack | member | strings": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "chat_purge_ack | operator | bare": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "chat_purge_ack | operator | lists": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "chat_purge_ack | operator | numbers": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
+ "chat_purge_ack | operator | strings": {
+ "audit": [],
+ "log": [
+ "WARNING Unknown MNP message type on DataChannel: %s"
+ ],
+ "sent": [],
+ "spawned": []
+ },
"client_diag | challenged | bare": {
"audit": [],
"log": [],
@@ -9771,7 +10159,7 @@
"subject": "link:gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -11870,7 +12258,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -11889,7 +12277,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -11908,7 +12296,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13009,7 +13397,7 @@
"req_id": 4242,
"token": null,
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13024,7 +13412,7 @@
"x"
],
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13037,7 +13425,7 @@
"req_id": 4242,
"token": 7,
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13050,7 +13438,7 @@
"req_id": 4242,
"token": "x",
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13063,7 +13451,7 @@
"req_id": 4242,
"token": null,
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13078,7 +13466,7 @@
"x"
],
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13091,7 +13479,7 @@
"req_id": 4242,
"token": 7,
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13104,7 +13492,7 @@
"req_id": 4242,
"token": "x",
"type": "pong",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13439,7 +13827,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13458,7 +13846,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13477,7 +13865,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13812,7 +14200,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13831,7 +14219,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -13850,7 +14238,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -14185,7 +14573,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -14204,7 +14592,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -14223,7 +14611,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -16850,7 +17238,7 @@
"subject": "{\"language\":null,\"token\":null}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
@@ -16893,7 +17281,7 @@
"subject": "{\"language\":\"x\",\"token\":\"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881\"}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.0"
+ "v": "6.1"
}
],
"spawned": []
diff --git a/packages/meshbay-node/tests/test_chat_purge.py b/packages/meshbay-node/tests/test_chat_purge.py
new file mode 100644
index 0000000..37bd954
--- /dev/null
+++ b/packages/meshbay-node/tests/test_chat_purge.py
@@ -0,0 +1,110 @@
+"""
+The operator's chat purge (`chat_purge`, MNP 6.1): every stored message of the
+group goes, signed like every operator instruction, and every connected member
+is told so their open chat empties.
+
+Driven through `_do_admin_response`, as in `test_admin_ops_mnp.py`: the node
+rebuilds the transcript from the operation it holds, and skipping that would
+test nothing.
+"""
+
+import base64
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_common.adminop import OP_CHAT_PURGE, admin_transcript
+from meshbay_node import ops
+from meshbay_node.chat import FORMAT_SEALED_V1, ChatStore
+from meshbay_node.roster import open_roster
+from test_admin_ops_mnp import GROUP, _drain, _last, _session
+
+
+@pytest.fixture
+async def roster(tmp_path):
+ r = await open_roster(tmp_path)
+ yield r
+ await r.close()
+
+
+async def _with_chat(tmp_path, roster, *, operator=True, messages=2):
+ session = await _session(tmp_path, roster, operator=operator)
+ store = ChatStore(tmp_path / "chat.db")
+ await store.open()
+ for i in range(messages):
+ await store.save_message(
+ "grenet", 0, b"ct", format=FORMAT_SEALED_V1, epoch=1,
+ device=b"d" * 32, nonce=bytes([i]) * 12, sig=b"s" * 64)
+ session.state["groups_ctx"][GROUP]["chat_store"] = store
+ # A second member connected to the group, to see the broadcast arrive.
+ other = []
+ session.state["groups_ctx"][GROUP]["_peers"] = {
+ "grenet": session, "bob": type("Peer", (), {"_send": other.append})()}
+ return session, store, other
+
+
+def _sign(session, challenge, sk=None):
+ transcript = admin_transcript(
+ op=OP_CHAT_PURGE, node_pk_b64=session._node_pk_b64(), group_id=GROUP,
+ subject=challenge["subject"], nonce=base64.b64decode(challenge["nonce"]),
+ ts=challenge["ts"])
+ sig = (sk or session.sk_op).sign(transcript)
+ session._do_admin_response({"op_id": challenge["op_id"],
+ "signature": base64.b64encode(sig).decode()})
+
+
+async def test_a_signed_purge_empties_the_chat_and_tells_the_group(tmp_path, roster):
+ session, store, other = await _with_chat(tmp_path, roster)
+
+ session._do_chat_purge({})
+ challenge = _last(session)
+ assert challenge["type"] == "admin_challenge"
+ assert challenge["op"] == OP_CHAT_PURGE and challenge["subject"] == GROUP
+ _sign(session, challenge)
+ await _drain(session)
+
+ assert await store.message_count() == 0
+ assert other[-1]["type"] == "chat_purge_ack" and other[-1]["removed"] == 2
+ assert _last(session)["type"] == "chat_purge_ack"
+ await store.close()
+
+
+async def test_the_purge_names_the_connection_group_and_no_other(tmp_path, roster):
+ """A group named in the message is not the one purged: the ack goes to this
+ connection's group, and so does the operation."""
+ session, store, _ = await _with_chat(tmp_path, roster)
+
+ session._do_chat_purge({"group_id": "h" * 32})
+
+ assert _last(session)["subject"] == GROUP
+ await store.close()
+
+
+async def test_a_signature_from_a_non_operator_key_purges_nothing(tmp_path, roster):
+ session, store, other = await _with_chat(tmp_path, roster)
+
+ session._do_chat_purge({})
+ _sign(session, _last(session), sk=Ed25519PrivateKey.generate())
+ await _drain(session)
+
+ assert _last(session)["type"] == "error"
+ assert await store.message_count() == 2
+ assert other == []
+ await store.close()
+
+
+async def test_no_challenge_without_an_operator(tmp_path, roster):
+ session, store, _ = await _with_chat(tmp_path, roster, operator=False)
+
+ session._do_chat_purge({})
+
+ assert _last(session)["type"] == "error"
+ assert session._admin_ops == {}
+ await store.close()
+
+
+async def test_a_group_without_a_chat_store_says_so(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True)
+
+ with pytest.raises(ops.OpError) as e:
+ await ops.purge_chat(session.state, GROUP)
+ assert e.value.status == 404