diff --git a/CHANGELOG.md b/CHANGELOG.md index 3834cd4a..1e028879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ * Web: MeshCore `@[Name]` mentions and replies no longer create, refresh, or revive nodes (#883) * Web: a live refresh no longer moves the chat: a scrolled-up reader keeps their place, the channel tab strip stops sliding back, and tab focus and text selection survive (#881, SPEC DR1/DR3) * Web: a MeshCore channel message stays on its sender's node and no longer moves to, or revives, a same-name node; a sender named by a stale roster is credited to the current key (#883) +* Web/Matrix: MeshCore channel messages, whose sender is matched by the name in the text, carry `sender_verified: false` in `GET /api/messages`; the chat marks them "unverified" and the Matrix bridge posts them as its appservice user instead of the node's puppet. In an invite-only room, invite the appservice user (SPEC SV1-SV5) * Data: the MeshCore ingestor no longer posts placeholder nodes for chat senders or mentions, so hidden-channel names stay hidden; the web app ignores such posts from older ingestors (#883) * Data: a MeshCore sender name shared by several roster contacts resolves to the most recently advertised one (#883) * Data: the MeshCore ingestor posts an advert from the radio's RX log only when its signature verifies, refusing small-order keys and replayed adverts, so a node's SNR, RSSI and hops now come from the first copy heard; roster contacts are unchanged (SPEC SG1-SG5) diff --git a/data/mesh_ingestor/CONTRACTS.md b/data/mesh_ingestor/CONTRACTS.md index dc1fc3c9..203f6038 100644 --- a/data/mesh_ingestor/CONTRACTS.md +++ b/data/mesh_ingestor/CONTRACTS.md @@ -568,6 +568,10 @@ only, from the operator's own RNS config. `GET /api/nodes` and `GET /api/nodes/:id` emit `synthetic: true` on a name-derived MeshCore placeholder row - a channel sender not yet matched to a keyed contact - and omit the key on every other row (no `synthetic: false`). Placeholders come only from ingested chat senders (see the `POST /api/nodes` placeholder note), never from mentions, and the reconciliation merge folds them into the real node once its contact is stored and the name is unambiguous (SPEC MR2). +### GET /api/messages sender flag (SPEC SV1/SV2) + +`GET /api/messages` and `GET /api/messages/:id` emit `sender_verified: false` on a MeshCore channel message (`protocol` `"meshcore"`, `to_id` `"^all"`) that has a `from_id`, and omit the key on every other row (no `sender_verified: true`). Such a message carries no sender key, only its `SenderName:` text prefix: its `from_id` is the ingestor's roster match of that name or the name-derived id, and the web app may re-map it by name (SPEC GN3, the placeholder merges above). `node_id` and `from_id` still name the matched node. The flag is computed when the row is read and is not stored. Meshtastic and Reticulum messages and MeshCore direct messages never carry it; the ingestor drops MeshCore direct messages before posting (`skipped-direct-message`). The Matrix bridge posts a flagged message as its appservice user, not as the node's puppet (SPEC SV4). + ### GET /api/destinations response shape One row per announced destination, newest `last_heard` first. diff --git a/matrix/README.md b/matrix/README.md index f587ca6b..a3ec6281 100644 --- a/matrix/README.md +++ b/matrix/README.md @@ -12,7 +12,7 @@ For each PotatoMesh node, the bridge creates (or uses) a Matrix puppet user: - Matrix localpart: `potato_` + the hex node id (without `!`), e.g. `!67fc83cb` → `@potato_67fc83cb:example.org` - Matrix display name: the node’s `long_name` from the PotatoMesh API -Messages from PotatoMesh are periodically fetched and forwarded to a single Matrix room as those puppet users. +Messages from PotatoMesh are periodically fetched and forwarded to a single Matrix room as those puppet users. MeshCore channel messages, which the API marks `sender_verified: false`, are posted as the appservice user (`sender_localpart`) instead, with the same body. --- @@ -240,6 +240,8 @@ listener. In Synapse’s `homeserver.yaml`, add the registration file under `app_service_config_files`, restart, and invite a puppet user to your target room (or use room ID directly). +In an invite-only room, also invite the appservice user (`@potatomesh-bridge:example.org` for the `sender_localpart` above). It posts the MeshCore channel messages. + The bridge validates inbound callbacks by comparing the `access_token` query param to `hs_token` - keep them in sync. @@ -324,6 +326,8 @@ The bridge will: * Send a formatted text message into `room_id` as that puppet. * Update and persist `bridge_state.json`. + For a message marked `sender_verified: false`, the bridge instead joins `room_id` as the appservice user and sends the message as that user, with no node lookup and no puppet. + Delete `bridge_state.json` if you want it to replay all currently available messages. --- diff --git a/matrix/src/main.rs b/matrix/src/main.rs index 155c92ef..24e3708b 100644 --- a/matrix/src/main.rs +++ b/matrix/src/main.rs @@ -316,7 +316,9 @@ async fn main() -> Result<()> { /// Whether `poll_once` can bridge this message at all: it needs a resolved /// sender (`node_id`, to puppet the Matrix user) and non-blank text (the /// Matrix body). Compacted rows lacking either — emoji reactions, unresolved -/// senders (ACCEPTANCE MB-A1) — are skipped with the watermark advanced. +/// senders (ACCEPTANCE MB-A1) — are skipped with the watermark advanced. A +/// row with `sender_verified: false` always has a `node_id` (SPEC SV2), even +/// though it is posted as the appservice user ([`posts_as_puppet`]). fn is_forwardable(msg: &PotatoMessage) -> bool { let has_text = msg .text @@ -326,6 +328,19 @@ fn is_forwardable(msg: &PotatoMessage) -> bool { has_text && msg.node_id.is_some() } +/// Whether a message goes out as the puppet of its `node_id`. A row the web +/// app serves with `sender_verified: false` names its sender only in its text +/// (a MeshCore channel message, SPEC SV1), which no key backs: it is posted as +/// the appservice user instead, its body unchanged, and no puppet speaks for +/// the named node (SPEC SV4). Every other row keeps its puppet. +fn posts_as_puppet(msg: &PotatoMessage) -> bool { + msg.sender_verified != Some(false) +} + +/// Post one message to Matrix. A row [`posts_as_puppet`] accepts goes out as +/// the puppet of its `node_id`; a row the web app serves with +/// `sender_verified: false` goes out as the appservice user, its body +/// unchanged (SPEC SV4). async fn handle_message( potato: &PotatoClient, matrix: &MatrixAppserviceClient, @@ -340,15 +355,22 @@ async fn handle_message( }; let text = msg.text.as_deref().unwrap_or(""); - let node = potato.get_node(node_id).await?; - let localpart = MatrixAppserviceClient::localpart_from_node_id(node_id); - let user_id = matrix.user_id(&localpart); + // The puppet that posts the message, or `None` for the appservice user. + let puppet = if posts_as_puppet(msg) { + let node = potato.get_node(node_id).await?; + let localpart = MatrixAppserviceClient::localpart_from_node_id(node_id); + let user_id = matrix.user_id(&localpart); - // Ensure puppet exists & has display name - matrix.ensure_user_registered(&localpart).await?; - matrix.ensure_user_joined_room(&user_id).await?; - let display_name = display_name_for_node(&node); - matrix.set_display_name(&user_id, &display_name).await?; + // Ensure puppet exists & has display name + matrix.ensure_user_registered(&localpart).await?; + matrix.ensure_user_joined_room(&user_id).await?; + let display_name = display_name_for_node(&node); + matrix.set_display_name(&user_id, &display_name).await?; + Some(user_id) + } else { + matrix.ensure_appservice_joined_room().await?; + None + }; // Format the bridged message. A compacted-away `lora_freq` renders as the // existing `0` "unknown" sentinel — collapse that to `None` to match the @@ -373,9 +395,18 @@ async fn handle_message( ); let (body, formatted_body) = format_message_bodies(&prefix, text); - matrix - .send_formatted_message_as(&user_id, &body, &formatted_body) - .await?; + match puppet.as_deref() { + Some(user_id) => { + matrix + .send_formatted_message_as(user_id, &body, &formatted_body) + .await? + } + None => { + matrix + .send_formatted_message_as_appservice(&body, &formatted_body) + .await? + } + } info!("Bridged message: {:?}", msg); state.update_with(msg); @@ -461,6 +492,7 @@ mod tests { reply_id: None, node_id: Some("!abcd1234".to_string()), protocol: Some("meshtastic".to_string()), + sender_verified: None, } } @@ -500,6 +532,21 @@ mod tests { })); } + #[test] + fn posts_as_puppet_unless_the_sender_is_not_verified() { + // No flag (every Meshtastic row, older web apps): the puppet posts. + assert!(posts_as_puppet(&sample_msg(1))); + assert!(posts_as_puppet(&PotatoMessage { + sender_verified: Some(true), + ..sample_msg(1) + })); + // Named only in its text (SPEC SV2): the appservice user posts. + assert!(!posts_as_puppet(&PotatoMessage { + sender_verified: Some(false), + ..sample_msg(1) + })); + } + /// The `poll_once` gate makes a sender-less message unreachable from the /// poll loop, but `handle_message` stays total: called directly with no /// `node_id` it returns `Ok` without touching any endpoint (no mock @@ -537,6 +584,114 @@ mod tests { assert_eq!(state.last_message_id, None); } + /// A line the web app serves with `sender_verified: false` names its + /// sender only in its text (SPEC SV1/SV2). `handle_message` posts it as + /// the appservice user, after joining that user to the room, with the body + /// unchanged; it neither looks the node up nor registers, joins or names a + /// puppet (SPEC SV4). + #[tokio::test] + async fn handle_message_posts_unverified_sender_as_appservice_user() { + let mut server = mockito::Server::new_async().await; + let join_path = r"/_matrix/client/v3/rooms/.+/join"; + + let mock_get_node = server + .mock("GET", mockito::Matcher::Regex(r"/api/nodes/.+".to_string())) + .expect(0) + .create(); + let mock_register = server + .mock("POST", "/_matrix/client/v3/register") + .match_query(mockito::Matcher::Any) + .expect(0) + .create(); + let mock_display = server + .mock( + "PUT", + mockito::Matcher::Regex(r"/_matrix/client/v3/profile/.+/displayname".to_string()), + ) + .match_query(mockito::Matcher::Any) + .expect(0) + .create(); + let mock_join_puppet = server + .mock("POST", mockito::Matcher::Regex(join_path.to_string())) + .match_query(mockito::Matcher::Regex("user_id=".to_string())) + .expect(0) + .create(); + let mock_join = server + .mock("POST", mockito::Matcher::Regex(join_path.to_string())) + .match_query(mockito::Matcher::Missing) + .match_header("authorization", "Bearer AS_TOKEN") + .with_status(200) + .expect(1) + .create(); + let mock_send = server + .mock( + "PUT", + mockito::Matcher::Regex( + r"/_matrix/client/v3/rooms/.+/send/m.room.message/.+".to_string(), + ), + ) + .match_query(mockito::Matcher::Missing) + .match_header("authorization", "Bearer AS_TOKEN") + .match_body(mockito::Matcher::PartialJson(serde_json::json!({ + "msgtype": "m.text", + "body": "`[MC][869][NA][Public]` Alice: Ping", + "format": "org.matrix.custom.html", + "formatted_body": "[MC][869][NA][Public] Alice: Ping", + }))) + .with_status(200) + .expect(1) + .create(); + + let http_client = reqwest::Client::new(); + let potato_client = PotatoClient::new( + http_client.clone(), + PotatomeshConfig { + base_url: server.url(), + poll_interval_secs: 1, + }, + ); + let matrix_client = MatrixAppserviceClient::new( + http_client, + MatrixConfig { + homeserver: server.url(), + as_token: "AS_TOKEN".to_string(), + hs_token: "HS_TOKEN".to_string(), + server_name: "example.org".to_string(), + room_id: "!roomid:example.org".to_string(), + }, + ); + let mut state = BridgeState::default(); + // The row exactly as `GET /api/messages` serves it. + let msg: PotatoMessage = serde_json::from_value(serde_json::json!({ + "id": 77, + "rx_time": 1785090651, + "rx_iso": "2026-07-26T18:30:51Z", + "from_id": "!a11ce001", + "to_id": "^all", + "channel": 0, + "portnum": "TEXT_MESSAGE_APP", + "text": "Alice: Ping", + "lora_freq": 869, + "modem_preset": "SF8/BW62/CR5", + "channel_name": "Public", + "protocol": "meshcore", + "node_id": "!a11ce001", + "sender_verified": false, + })) + .expect("flagged row parses"); + + let result = handle_message(&potato_client, &matrix_client, &mut state, &msg).await; + + assert!(result.is_ok(), "{result:?}"); + mock_get_node.assert(); + mock_register.assert(); + mock_display.assert(); + mock_join_puppet.assert(); + mock_join.assert(); + mock_send.assert(); + assert_eq!(state.last_message_id, Some(77)); + } + #[test] fn format_message_bodies_escape_html() { let (body, formatted) = format_message_bodies("[868][LF]", "Hello <&>"); @@ -1074,6 +1229,9 @@ mod tests { let mut server = mockito::Server::new_async().await; + // The second row is the production row of the outage, as the web app + // serves it since SPEC SV2: a MeshCore channel line names its sender + // only in its text, so it carries `sender_verified: false`. let mock_msgs = server .mock("GET", "/api/messages") .match_query(mockito::Matcher::Any) @@ -1082,7 +1240,7 @@ mod tests { .with_body( r#"[ {"id":1,"rx_time":10,"rx_iso":"2026-07-26T00:00:00Z","from_id":"!aaaaaaaa","to_id":"^all","channel":1,"portnum":"TEXT_MESSAGE_APP","text":"Ping","lora_freq":868,"modem_preset":"MediumFast","channel_name":"TEST","node_id":"!aaaaaaaa"}, - {"id":2,"rx_time":20,"rx_iso":"2026-07-26T18:30:51Z","from_id":"!88e00b48","to_id":"^all","channel":24,"portnum":"TEXT_MESSAGE_APP","text":"lilygo: moin moin","lora_freq":869,"modem_preset":"SF8/BW62/CR5","snr":-5.25,"ingestor":"!930d4a21","protocol":"meshcore","node_id":"!88e00b48"} + {"id":2,"rx_time":20,"rx_iso":"2026-07-26T18:30:51Z","from_id":"!88e00b48","to_id":"^all","channel":24,"portnum":"TEXT_MESSAGE_APP","text":"lilygo: moin moin","lora_freq":869,"modem_preset":"SF8/BW62/CR5","snr":-5.25,"ingestor":"!930d4a21","protocol":"meshcore","node_id":"!88e00b48","sender_verified":false} ]"#, ) .create(); @@ -1094,56 +1252,74 @@ mod tests { .with_header("content-type", "application/json") .with_body(r#"{"node_id":"!aaaaaaaa","long_name":"Node A","short_name":"NA"}"#) .create(); + // The flagged row gets no puppet (SPEC SV4): no node lookup, and only + // the first row's puppet is registered, joined and named. let mock_node_b = server .mock("GET", "/api/nodes/%2188e00b48") .match_query(mockito::Matcher::Any) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"node_id":"!88e00b48","long_name":"lilygo","short_name":"LG"}"#) + .expect(0) .create(); - let _mock_register = server + let puppet_a = mockito::Matcher::UrlEncoded( + "user_id".to_string(), + "@potato_aaaaaaaa:example.org".to_string(), + ); + let mock_register = server .mock("POST", "/_matrix/client/v3/register") .match_query(mockito::Matcher::Any) .with_status(200) - .expect(2) + .expect(1) .create(); - let _mock_join = server + let mock_join_puppet = server .mock( "POST", mockito::Matcher::Regex(r"/_matrix/client/v3/rooms/.+/join".to_string()), ) - .match_query(mockito::Matcher::Any) + .match_query(puppet_a.clone()) .with_status(200) - .expect(2) + .expect(1) .create(); - let _mock_display = server + let mock_join_appservice = server + .mock( + "POST", + mockito::Matcher::Regex(r"/_matrix/client/v3/rooms/.+/join".to_string()), + ) + .match_query(mockito::Matcher::Missing) + .with_status(200) + .expect(1) + .create(); + let mock_display = server .mock( "PUT", mockito::Matcher::Regex(r"/_matrix/client/v3/profile/.+/displayname".to_string()), ) .match_query(mockito::Matcher::Any) .with_status(200) - .expect(2) + .expect(1) .create(); // The compacted row must render EMPTY channel brackets — the trailing - // `[]` — exactly like the web frontend renders unknown metadata. + // `[]` — exactly like the web frontend renders unknown metadata. Being + // flagged, it goes out as the appservice user (no `user_id`), its body + // unchanged. let mock_send_compacted = server .mock( "PUT", mockito::Matcher::Regex(r"/_matrix/client/v3/rooms/.+/send/.+".to_string()), ) - .match_query(mockito::Matcher::Any) + .match_query(mockito::Matcher::Missing) .match_body(mockito::Matcher::PartialJson(serde_json::json!({ "body": "`[MC][869][NA][]` lilygo: moin moin", }))) .with_status(200) .create(); - let _mock_send_rest = server + let mock_send_puppet = server .mock( "PUT", mockito::Matcher::Regex(r"/_matrix/client/v3/rooms/.+/send/.+".to_string()), ) - .match_query(mockito::Matcher::Any) + .match_query(puppet_a) .with_status(200) .create(); @@ -1169,7 +1345,12 @@ mod tests { mock_msgs.assert(); mock_node_a.assert(); mock_node_b.assert(); + mock_register.assert(); + mock_join_puppet.assert(); + mock_join_appservice.assert(); + mock_display.assert(); mock_send_compacted.assert(); + mock_send_puppet.assert(); // Both messages forwarded: the watermark reached the compacted row. assert_eq!( @@ -1703,6 +1884,7 @@ mod tests { reply_id: None, node_id: Some("!abcd1234".to_string()), protocol: None, + sender_verified: None, }; assert_handle_message_emits_body(msg, "[MT][0][??][]", "Hi").await; } diff --git a/matrix/src/matrix.rs b/matrix/src/matrix.rs index 6ed2faab..c4f78e13 100644 --- a/matrix/src/matrix.rs +++ b/matrix/src/matrix.rs @@ -156,16 +156,43 @@ impl MatrixAppserviceClient { } } + /// Query string that makes an appservice request act as `user_id`, or an + /// empty one, which acts as the appservice user itself (the + /// registration's `sender_localpart`). + fn user_id_query(user_id: Option<&str>) -> String { + user_id + .map(|user_id| format!("?user_id={}", urlencoding::encode(user_id))) + .unwrap_or_default() + } + + /// Who a request acts as, for logs and errors. + fn acting_user(user_id: Option<&str>) -> &str { + user_id.unwrap_or("the appservice user") + } + /// Ensure the puppet user is joined to the configured room. pub async fn ensure_user_joined_room(&self, user_id: &str) -> anyhow::Result<()> { + self.join_room_as(Some(user_id)).await + } + + /// Ensure the appservice user itself is joined to the configured room. It + /// posts the messages whose sender is not verified (SPEC SV4). + pub async fn ensure_appservice_joined_room(&self) -> anyhow::Result<()> { + self.join_room_as(None).await + } + + /// Join the configured room as `user_id`, or as the appservice user when + /// `None`. + async fn join_room_as(&self, user_id: Option<&str>) -> anyhow::Result<()> { #[derive(Serialize)] struct JoinReq {} let encoded_room = urlencoding::encode(&self.cfg.room_id); - let encoded_user = urlencoding::encode(user_id); let url = format!( - "{}/_matrix/client/v3/rooms/{}/join?user_id={}", - self.cfg.homeserver, encoded_room, encoded_user + "{}/_matrix/client/v3/rooms/{}/join{}", + self.cfg.homeserver, + encoded_room, + Self::user_id_query(user_id) ); let resp = self @@ -182,7 +209,7 @@ impl MatrixAppserviceClient { let body_snip = resp.text().await.unwrap_or_default(); Err(anyhow::anyhow!( "Matrix join failed for {} in {} with status {} ({})", - user_id, + Self::acting_user(user_id), self.cfg.room_id, status, body_snip @@ -196,6 +223,28 @@ impl MatrixAppserviceClient { user_id: &str, body_text: &str, formatted_body: &str, + ) -> anyhow::Result<()> { + self.send_formatted(Some(user_id), body_text, formatted_body) + .await + } + + /// Send a text message with HTML formatting into the configured room as + /// the appservice user itself (SPEC SV4). + pub async fn send_formatted_message_as_appservice( + &self, + body_text: &str, + formatted_body: &str, + ) -> anyhow::Result<()> { + self.send_formatted(None, body_text, formatted_body).await + } + + /// Send a formatted text message as `user_id`, or as the appservice user + /// when `None`. + async fn send_formatted( + &self, + user_id: Option<&str>, + body_text: &str, + formatted_body: &str, ) -> anyhow::Result<()> { #[derive(Serialize)] struct MsgContent<'a> { @@ -207,11 +256,13 @@ impl MatrixAppserviceClient { let txn_id = self.txn_counter.fetch_add(1, Ordering::SeqCst); let encoded_room = urlencoding::encode(&self.cfg.room_id); - let encoded_user = urlencoding::encode(user_id); let url = format!( - "{}/_matrix/client/v3/rooms/{}/send/m.room.message/{}?user_id={}", - self.cfg.homeserver, encoded_room, txn_id, encoded_user + "{}/_matrix/client/v3/rooms/{}/send/m.room.message/{}{}", + self.cfg.homeserver, + encoded_room, + txn_id, + Self::user_id_query(user_id) ); let content = MsgContent { @@ -232,6 +283,7 @@ impl MatrixAppserviceClient { if !resp.status().is_success() { let status = resp.status(); let body_snip = resp.text().await.unwrap_or_default(); + let user_id = Self::acting_user(user_id); tracing::warn!( "Failed to send formatted message as {}: status {}, body: {}", @@ -559,4 +611,108 @@ mod tests { mock.assert(); assert!(result.is_ok()); } + + /// Build a client for `server` with the dummy config. + fn client_for(server: &mockito::ServerGuard) -> MatrixAppserviceClient { + let mut cfg = dummy_cfg(); + cfg.homeserver = server.url(); + MatrixAppserviceClient::new(reqwest::Client::new(), cfg) + } + + /// The appservice user joins with the `as_token` and no `user_id`, so the + /// homeserver acts as the registration's `sender_localpart` (SPEC SV4). + #[tokio::test] + async fn test_ensure_appservice_joined_room_success() { + let mut server = mockito::Server::new_async().await; + let path = format!( + "/_matrix/client/v3/rooms/{}/join", + urlencoding::encode("!roomid:example.org") + ); + let mock = server + .mock("POST", path.as_str()) + .match_query(mockito::Matcher::Missing) + .match_header("authorization", "Bearer AS_TOKEN") + .with_status(200) + .create(); + + let result = client_for(&server).ensure_appservice_joined_room().await; + + mock.assert(); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_ensure_appservice_joined_room_fail() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock( + "POST", + mockito::Matcher::Regex(r"/_matrix/client/v3/rooms/.+/join".to_string()), + ) + .match_query(mockito::Matcher::Missing) + .with_status(403) + .create(); + + let result = client_for(&server).ensure_appservice_joined_room().await; + + mock.assert(); + let err = result.expect_err("a refused join fails"); + assert!(err.to_string().contains("the appservice user"), "{err}"); + } + + /// The appservice user posts with the `as_token` and no `user_id`, the + /// body unchanged (SPEC SV4). + #[tokio::test] + async fn test_send_formatted_message_as_appservice_success() { + let mut server = mockito::Server::new_async().await; + let client = client_for(&server); + let path = format!( + "/_matrix/client/v3/rooms/{}/send/m.room.message/{}", + urlencoding::encode("!roomid:example.org"), + client.txn_counter.load(Ordering::SeqCst) + ); + let mock = server + .mock("PUT", path.as_str()) + .match_query(mockito::Matcher::Missing) + .match_header("authorization", "Bearer AS_TOKEN") + .match_body(mockito::Matcher::PartialJson(serde_json::json!({ + "msgtype": "m.text", + "body": "`[meta]` Alice: hello", + "format": "org.matrix.custom.html", + "formatted_body": "[meta] Alice: hello", + }))) + .with_status(200) + .create(); + + let result = client + .send_formatted_message_as_appservice( + "`[meta]` Alice: hello", + "[meta] Alice: hello", + ) + .await; + + mock.assert(); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_send_formatted_message_as_appservice_fail() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock( + "PUT", + mockito::Matcher::Regex(r"/_matrix/client/v3/rooms/.+/send/.+".to_string()), + ) + .match_query(mockito::Matcher::Missing) + .with_status(500) + .create(); + + let result = client_for(&server) + .send_formatted_message_as_appservice("`[meta]` hello", "[meta] hello") + .await; + + mock.assert(); + let err = result.expect_err("a refused send fails"); + assert!(err.to_string().contains("the appservice user"), "{err}"); + } } diff --git a/matrix/src/potatomesh.rs b/matrix/src/potatomesh.rs index 30b12609..8523cd52 100644 --- a/matrix/src/potatomesh.rs +++ b/matrix/src/potatomesh.rs @@ -81,6 +81,12 @@ pub struct PotatoMessage { /// "meshcore". Optional because historical payloads predate the field. #[serde(default)] pub protocol: Option, + /// `Some(false)` when the web app attributed the sender by the `Name:` + /// prefix of `text` rather than by an id the packet carries: a MeshCore + /// channel message (SPEC SV1/SV2). Absent on every other row, and from + /// web apps that predate the field. + #[serde(default)] + pub sender_verified: Option, } #[derive(Debug, Default, Clone)] @@ -307,6 +313,42 @@ mod tests { assert_eq!(msgs[0].protocol.as_deref(), Some("meshcore")); } + /// The web app flags a MeshCore channel line whose sender it matched by + /// name with `sender_verified: false` and leaves the key off every other + /// row (SPEC SV2); both shapes parse in one batch. + #[test] + fn deserialize_sender_verified_flag() { + let json = r#" + [ + { + "id": 1, + "rx_time": 1785090651, + "rx_iso": "2026-07-26T18:30:51Z", + "from_id": "!a11ce001", + "to_id": "^all", + "text": "Alice: Ping", + "protocol": "meshcore", + "node_id": "!a11ce001", + "sender_verified": false + }, + { + "id": 2, + "rx_time": 1785090652, + "rx_iso": "2026-07-26T18:30:52Z", + "from_id": "!0badc0de", + "to_id": "^all", + "text": "Ping", + "protocol": "meshtastic", + "node_id": "!0badc0de" + } + ] + "#; + + let msgs: Vec = serde_json::from_str(json).expect("flagged batch parses"); + assert_eq!(msgs[0].sender_verified, Some(false)); + assert_eq!(msgs[1].sender_verified, None); + } + /// Regression (live outage 2026-07-26, ACCEPTANCE MB-A1): the web API /// compacts NULL/empty columns *out* of each row (`compact_api_row`), and /// `CONTRACTS.md` declares `channel_name` present "only when not encrypted diff --git a/web/lib/potato_mesh/application/data_processing/meshcore_chat.rb b/web/lib/potato_mesh/application/data_processing/meshcore_chat.rb index 3edf4930..e840a720 100644 --- a/web/lib/potato_mesh/application/data_processing/meshcore_chat.rb +++ b/web/lib/potato_mesh/application/data_processing/meshcore_chat.rb @@ -52,6 +52,26 @@ module PotatoMesh name.empty? ? nil : name end + # Whether a stored message row's sender was attributed by name only, so + # the API serves it with +sender_verified: false+ (SPEC SV1/SV2). + # + # A MeshCore channel message (+to_id+ +"^all"+) carries no sender key, + # only the typed +"Name:"+ prefix of its text. Its +from_id+ is + # therefore always a name match: the ingestor's roster lookup or the + # name-derived id, which {#resolve_meshcore_channel_sender} and the + # placeholder merges may re-map by name again. Every other sender comes + # from an id the packet carries: a MeshCore direct message names its + # sender by key (and the ingestor drops it before posting), and + # Meshtastic and Reticulum senders are set by the sending node. A row + # without a +from_id+ attributes no sender, so it is not flagged. + # + # @param row [Hash] message row with the stored +"protocol"+, +"to_id"+ + # and +"from_id"+ columns. + # @return [Boolean] true for a MeshCore channel row with a sender. + def meshcore_sender_name_attributed?(row) + row["protocol"] == "meshcore" && row["to_id"].to_s == "^all" && !string_or_nil(row["from_id"]).nil? + end + # Rank a candidate MeshCore sender id by the strength of its identity # evidence (SPEC MR3). # diff --git a/web/lib/potato_mesh/application/queries/chat_queries.rb b/web/lib/potato_mesh/application/queries/chat_queries.rb index 055b0cfa..03673454 100644 --- a/web/lib/potato_mesh/application/queries/chat_queries.rb +++ b/web/lib/potato_mesh/application/queries/chat_queries.rb @@ -25,7 +25,9 @@ module PotatoMesh # @param since [Integer] unix timestamp threshold; messages with rx_time older than this are excluded. # @param before [Integer, nil] inclusive upper-bound rx_time cursor used for # backward pagination (issue #796); messages newer than this are excluded. - # @return [Array] compacted message rows safe for API responses. + # @return [Array] compacted message rows safe for API responses; a + # row whose sender was matched by name carries +sender_verified: false+ + # (see {DataProcessing#meshcore_sender_name_attributed?}). def query_messages(limit, node_ref: nil, include_encrypted: false, since: 0, before: nil, protocol: nil) limit = coerce_query_limit(limit) now = Time.now.to_i @@ -119,6 +121,10 @@ module PotatoMesh end r["node_id"] = node_id if node_id + # A MeshCore channel sender is matched by name, never by an id the + # packet carries: flag it, and leave the key off every other row as + # the node API does with +synthetic+ (SPEC SV2). + r["sender_verified"] = false if meshcore_sender_name_attributed?(r) if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.strip.empty?) debug_log( diff --git a/web/public/assets/js/app/__tests__/chat-sender-marker.test.js b/web/public/assets/js/app/__tests__/chat-sender-marker.test.js new file mode 100644 index 00000000..63435c63 --- /dev/null +++ b/web/public/assets/js/app/__tests__/chat-sender-marker.test.js @@ -0,0 +1,173 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { + SENDER_UNVERIFIED_LABEL, + SENDER_UNVERIFIED_TEXT, + formatChatSenderMarker, + isSenderUnverified, +} from '../chat-sender-marker.js'; +import { renderMessages } from '../node-page/messages.js'; +import { withApp, innerHtml } from './main-app-test-helpers.js'; + +/** A MeshCore channel line as ``GET /api/messages`` serves it (SPEC SV2). */ +const NAMED = Object.freeze({ + id: 401, + text: 'Alice: see you at the hut', + rx_time: 1_791_280_000, + protocol: 'meshcore', + to_id: '^all', + from_id: '!a11ce001', + node_id: '!a11ce001', + hops: 2, + sender_verified: false, +}); + +/** A Meshtastic line: its sender comes from the packet, so no flag. */ +const MESHTASTIC = Object.freeze({ + id: 402, + text: 'Alice: hello', + rx_time: 1_791_280_000, + protocol: 'meshtastic', + to_id: '^all', + from_id: '!0badc0de', + node_id: '!0badc0de', + hops: 1, +}); + +/** The hydrated sender node the dashboard attaches to a line. */ +const ALICE_NODE = Object.freeze({ short_name: 'ALCE', role: 'COMPANION', protocol: 'meshcore' }); + +const MARKER = ' unverified'; + +/** + * Badge renderer standing in for the dashboard's ``renderShortHtml``. + * + * @param {string} short Short name. + * @returns {string} Badge HTML. + */ +function badge(short) { + return `${short}`; +} + +// --- the module --- + +test('the marker shows "unverified", titled "Sender not verified"', () => { + assert.equal(SENDER_UNVERIFIED_TEXT, 'unverified'); + assert.equal(SENDER_UNVERIFIED_LABEL, 'Sender not verified'); +}); + +test('isSenderUnverified is true only for sender_verified false or a badge named from the text', () => { + assert.equal(isSenderUnverified(NAMED), true); + assert.equal(isSenderUnverified(MESHTASTIC), false); + for (const value of [undefined, null, true, 0, '', 'false']) { + assert.equal(isSenderUnverified({ ...NAMED, sender_verified: value }), false, `${String(value)} is not the flag`); + } + for (const value of [null, undefined, 'x', 42]) { + assert.equal(isSenderUnverified(value), false, `${String(value)} is no message`); + } + assert.equal(isSenderUnverified({ ...MESHTASTIC }, { senderFromText: true }), true); + assert.equal(isSenderUnverified(MESHTASTIC, { senderFromText: false }), false); + assert.equal(isSenderUnverified(MESHTASTIC, {}), false); +}); + +test('formatChatSenderMarker renders a titled "unverified" chip with a leading space', () => { + assert.equal(formatChatSenderMarker(NAMED), MARKER); + assert.equal(formatChatSenderMarker(MESHTASTIC, { senderFromText: true }), MARKER); +}); + +test('formatChatSenderMarker renders nothing for a sender from an id the packet carries', () => { + assert.equal(formatChatSenderMarker(MESHTASTIC), ''); + assert.equal(formatChatSenderMarker({ ...NAMED, sender_verified: undefined }), ''); + assert.equal(formatChatSenderMarker(null), ''); +}); + +// --- the dashboard chat line --- + +test('the dashboard chat line marks the sender after the badge and before the route chip, outside the prefix', () => { + withApp((t) => { + const html = innerHtml(t.createMessageChatEntry({ ...NAMED, node: ALICE_NODE })); + const marker = html.indexOf(MARKER); + assert.ok(marker > html.indexOf('short-name'), 'marker follows the sender badge'); + assert.ok(marker < html.indexOf('class="chat-route-chip"'), 'marker precedes the route chip'); + assert.ok(marker < html.indexOf('see you at the hut'), 'marker precedes the text'); + assert.match(html, /^\[[^\]]*\]\[[^\]]*\]\[[^\]]*\] /, 'the bracketed 19ch prefix is unchanged'); + }); +}); + +test('the dashboard chat line has no marker for a Meshtastic sender or an unflagged MeshCore line', () => { + withApp((t) => { + const meshtastic = innerHtml(t.createMessageChatEntry({ ...MESHTASTIC, node: { short_name: 'BOB' } })); + assert.ok(!meshtastic.includes('chat-sender-unverified'), meshtastic); + const unflagged = innerHtml( + t.createMessageChatEntry({ ...NAMED, sender_verified: undefined, node: ALICE_NODE }), + ); + assert.ok(!unflagged.includes('chat-sender-unverified'), unflagged); + }); +}); + +test('the dashboard marks a MeshCore channel line whose badge it named from the text itself', () => { + withApp((t) => { + // An ingestor before 0.6.0 posts a channel line with no sender id; the + // renderer then names the badge from the "Alice:" prefix on its own. + const html = innerHtml(t.createMessageChatEntry({ + id: 403, + text: 'Alice: no sender id', + rx_time: 1_791_280_000, + protocol: 'meshcore', + to_id: '^all', + node: null, + })); + assert.ok(html.indexOf(MARKER) > html.indexOf('short-name'), html); + }); +}); + +// --- the node page --- + +test('the node page marks the sender after the badge and before the route chip', () => { + const node = { shortName: 'ALCE', longName: 'Alice', role: 'COMPANION', nodeId: '!a11ce001', protocol: 'meshcore' }; + const html = renderMessages([{ ...NAMED, node: ALICE_NODE }], badge, node); + const marker = html.indexOf(MARKER); + assert.ok(marker > html.indexOf('short-name'), 'marker follows the sender badge'); + assert.ok(marker < html.indexOf('class="chat-route-chip"'), 'marker precedes the route chip'); + assert.ok(marker < html.indexOf('see you at the hut'), 'marker precedes the text'); + // The node page names a MeshCore channel badge from the text when the line + // carries no hydrated node; that badge is marked too. + const fromText = renderMessages([{ ...NAMED, sender_verified: undefined }], badge, node); + assert.ok(fromText.indexOf(MARKER) > fromText.indexOf('short-name'), fromText); +}); + +test('the node page has no marker for a Meshtastic sender', () => { + const node = { shortName: 'BOB', longName: 'Bob', role: 'CLIENT', nodeId: '!0badc0de', protocol: 'meshtastic' }; + const html = renderMessages([{ ...MESHTASTIC, node: { short_name: 'BOB' } }], badge, node); + assert.ok(html.includes('short-name') && !html.includes('chat-sender-unverified'), html); +}); + +// --- the stylesheet --- + +test('base.css keeps the marker out of the hanging indent and lets the line wrap', () => { + const cssPath = fileURLToPath(new URL('../../../styles/base.css', import.meta.url)); + const rule = readFileSync(cssPath, 'utf8').match(/\.chat-sender-unverified \{[^}]*\}/); + assert.ok(rule, '.chat-sender-unverified rule exists'); + assert.match(rule[0], /text-indent: 0;/); + assert.match(rule[0], /display: inline-block;/); + assert.doesNotMatch(rule[0], /white-space: nowrap/, 'the line may wrap (LC3)'); +}); diff --git a/web/public/assets/js/app/chat-sender-marker.js b/web/public/assets/js/app/chat-sender-marker.js new file mode 100644 index 00000000..7b649922 --- /dev/null +++ b/web/public/assets/js/app/chat-sender-marker.js @@ -0,0 +1,75 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Unverified-sender marker for chat lines (SPEC SV3). + * + * A MeshCore channel message names its sender only in the ``Name:`` prefix of + * its text, which no key backs. The web app serves such a row with + * ``sender_verified: false`` (SPEC SV2), and the dashboard chat and the node + * page mark the line with an "unverified" chip after the sender badge, titled + * "Sender not verified". Visible text, not a glyph: a tooltip never shows on a + * touch screen, and ``?`` already marks a badge whose node is unknown. A line + * whose sender comes from an id the packet carries gets no marker. + * + * @module chat-sender-marker + */ + +/** + * Tooltip and accessible description of the marker. + * + * @type {string} + */ +export const SENDER_UNVERIFIED_LABEL = 'Sender not verified'; + +/** + * Visible text of the marker. + * + * @type {string} + */ +export const SENDER_UNVERIFIED_TEXT = 'unverified'; + +/** + * Whether a chat line's sender was attributed by name rather than by an id + * the packet carries. + * + * @param {?Object} message Message payload. + * @param {{ senderFromText?: boolean }} [options] ``senderFromText``: the + * renderer named the sender badge from the line's ``Name:`` prefix itself, + * which it does for a MeshCore channel line that came with no sender id + * (posted by an ingestor before 0.6.0). + * @returns {boolean} ``true`` when the row carries ``sender_verified: false`` + * or the badge was named from the text. + */ +export function isSenderUnverified(message, { senderFromText = false } = {}) { + if (senderFromText === true) return true; + return message != null && typeof message === 'object' && message.sender_verified === false; +} + +/** + * Render the unverified-sender marker of a chat line, placed right after the + * sender badge and outside the 19ch prefix (FU9). + * + * @param {?Object} message Message payload. + * @param {{ senderFromText?: boolean }} [options] See + * {@link isSenderUnverified}. + * @returns {string} HTML for the marker with a leading space, or ``''`` for a + * sender that came from an id the packet carries. + */ +export function formatChatSenderMarker(message, options = {}) { + if (!isSenderUnverified(message, options)) return ''; + return ` ${SENDER_UNVERIFIED_TEXT}`; +} diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 56babbcc..b6e852ac 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -92,6 +92,7 @@ import { formatChatPresetTag } from './chat-format.js'; import { formatChatRouteChip } from './chat-route-chip.js'; +import { formatChatSenderMarker } from './chat-sender-marker.js'; import { initializeInstanceSelector } from './instance-selector.js'; import { initializeMobileMenu } from './mobile-menu.js'; import { MESSAGE_LIMIT, normaliseMessageLimit } from './message-limit.js'; @@ -3619,8 +3620,9 @@ export function initializeApp(config) { // Sender badge: prefer the ingestor-hydrated node; fall back to the // MeshCore name-based lookup performed inside the shared renderer for // channel messages whose sender wasn't yet known to the contacts roster. + const senderFromText = !m.node && Boolean(meshcoreSenderNode); let short; - if (!m.node && meshcoreSenderNode) { + if (senderFromText) { short = renderShortHtml( meshcoreSenderNode.short_name ?? meshcoreSenderNode.shortName, meshcoreSenderNode.role, @@ -3636,11 +3638,13 @@ export function initializeApp(config) { frequency: metadata.frequency ? escapeHtml(metadata.frequency) : '' }); const presetTag = formatChatPresetTag({ presetCode: metadata.presetCode }); - // Route chip (hops + flood scope) after the sender badge (SPEC SC7). + // A sender named only by the text gets a marker right after its badge + // (SPEC SV3), then the route chip (hops + flood scope, SPEC SC7). + const senderMarker = formatChatSenderMarker(m, { senderFromText }); const routeChip = formatChatRouteChip(m); return { className: 'chat-entry-msg', - html: `${prefix}${presetTag} ${nodeProtocolPrefix}${short}${routeChip} ${text}` + html: `${prefix}${presetTag} ${nodeProtocolPrefix}${short}${senderMarker}${routeChip} ${text}` }; } diff --git a/web/public/assets/js/app/node-page/messages.js b/web/public/assets/js/app/node-page/messages.js index 94fa0ea9..8c5be82a 100644 --- a/web/public/assets/js/app/node-page/messages.js +++ b/web/public/assets/js/app/node-page/messages.js @@ -29,6 +29,7 @@ import { formatChatPresetTag, } from '../chat-format.js'; import { formatChatRouteChip } from '../chat-route-chip.js'; +import { formatChatSenderMarker } from '../chat-sender-marker.js'; import { buildMessageIndex } from '../message-replies.js'; import { renderChatEntryContent } from '../chat-entry-renderer.js'; import { formatMessageTimestamp } from '../node-page-charts.js'; @@ -209,8 +210,13 @@ export function renderMessages(messages, renderShortHtml, node, globalNodesById ?? null, source: senderNode ?? fallbackNode?.rawSources?.node ?? fallbackNode, }); + // A sender named only by the text gets a marker right after its badge + // (SPEC SV3). + const senderMarker = formatChatSenderMarker(message, { + senderFromText: senderNode !== null && senderNode === meshcoreSenderNode, + }); - return `
${prefix}${presetTag}${channelTag} ${protocolIconHtml}${badgeHtml}${routeChip} ${bodyHtml}
`; + return `
${prefix}${presetTag}${channelTag} ${protocolIconHtml}${badgeHtml}${senderMarker}${routeChip} ${bodyHtml}
`; }) .filter(item => item != null); if (items.length === 0) return ''; diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index 5a3e429c..8040d8de 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -3254,6 +3254,22 @@ button.legend-item[aria-pressed="true"] { font-size: 0.85em; } +/* Unverified-sender marker (SPEC SV3): an "unverified" chip after the sender + badge of a line whose sender is named only by its text, outside the 19ch + prefix (FU9). It resets the inherited hanging indent like the route chip, + and has no nowrap, so the line still wraps on a narrow panel (LC3). The + dashed border sets it apart from the route chip. */ +.chat-sender-unverified { + display: inline-block; + text-indent: 0; + padding: 0 4px; + border: 1px dashed var(--muted); + border-radius: 4px; + color: var(--muted); + font-size: 0.85em; + cursor: help; +} + /* Chat date dividers separate days spatially (UX11, D-040). */ .chat-entry-date { border-top: 1px solid var(--line); diff --git a/web/spec/meshcore_sender_verified_spec.rb b/web/spec/meshcore_sender_verified_spec.rb new file mode 100644 index 00000000..0ae749ed --- /dev/null +++ b/web/spec/meshcore_sender_verified_spec.rb @@ -0,0 +1,188 @@ +# Copyright © 2025-26 l5yth & contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require "spec_helper" +require "json" +require_relative "support/data_processing_harness" + +# MeshCore sender-verified flag (SPEC SV1/SV2; ACCEPTANCE SV-A1). A MeshCore +# channel message carries no sender key, only the typed "Name:" prefix of its +# text: the ingestor matches that name against its roster or derives an id +# from it, and the web app may re-map the id by name again (GN3, the +# placeholder merge). GET /api/messages and GET /api/messages/:id serve such a +# row with `sender_verified: false` and omit the key on every other row. +RSpec.describe "MeshCore sender-verified flag" do + include_context "with isolated db" + include MeshcoreNodeSeeds + + let(:app) { Sinatra::Application } + let(:harness_class) { DataProcessingHarness.build } + + subject(:dp) { harness_class.new } + + let(:day) { 86_400 } + # Alice: a keyed MeshCore contact, live (heard three days ago). + let(:alice) { "!a11ce001" } + # The id the ingestor derives from the name "Alice" when its roster lacks + # her (the SHA-256 prefix of +_derive_synthetic_node_id+). + let(:alice_derived_id) { "!3bc51062" } + # The MeshCore host whose ingestor posts the lines. + let(:host) { "!634069bc" } + let(:api_token) { "sender-verified-spec-token" } + let(:auth_headers) do + { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{api_token}" } + end + + before do + @original_token = ENV["API_TOKEN"] + ENV["API_TOKEN"] = api_token + PotatoMesh::App::ApiCache.invalidate_all + end + + after do + @original_token.nil? ? ENV.delete("API_TOKEN") : ENV["API_TOKEN"] = @original_token + PotatoMesh::App::ApiCache.invalidate_all + end + + # A MeshCore channel message as the ingestor posts it. + # + # @param id [Integer] message id. + # @param from_id [String, nil] sender id; nil leaves the key out. + # @param text [String] message text. + # @return [Hash] POST /api/messages payload. + def channel_message(id, from_id, text) + { + "id" => id, "rx_time" => now - 60, "from_id" => from_id, "to_id" => "^all", + "channel" => 0, "channel_name" => "Public", "text" => text, + "portnum" => "TEXT_MESSAGE_APP", "protocol" => "meshcore", "ingestor" => host, + }.compact + end + + # POST one message through the authenticated ingest route. + # + # @param payload [Hash] message payload. + # @return [void] + def post_message(payload) + post "/api/messages", payload.to_json, auth_headers + expect(last_response.status).to eq(201) + end + + # Fetch +path+ and return the served row with +id+. + # + # @param path [String] GET route. + # @param id [Integer] message id. + # @return [Hash, nil] the served row. + def served_row(path, id) + get path + expect(last_response).to be_ok + JSON.parse(last_response.body).find { |row| row["id"] == id } + end + + # Store a keyed MeshCore contact named "Alice". + # + # @param node_id [String] canonical node id. + # @param key_byte [String] two hex digits repeated into the public key. + # @param heard [Integer] unix seconds of the contact record. + # @return [void] + def seed_alice(node_id, key_byte, heard) + db = open_db + seed_keyed_node(db, node_id, "Alice", key_byte, heard) + ensure + db&.close + end + + describe "a MeshCore channel line whose sender is named only in its text" do + before { seed_alice(alice, "a1", now - 3 * day) } + + # Both routes serve the line as Alice's, flagged. + # + # @param id [Integer] message id. + # @return [void] + def expect_flagged_as_alice(id) + [served_row("/api/messages", id), served_row("/api/messages/#{alice}", id)].each do |row| + expect(row).to include("node_id" => alice, "sender_verified" => false) + end + end + + it "is served with sender_verified false when posted with the key id the roster matched" do + post_message(channel_message(101, alice, "Alice: see you at the hut")) + expect_flagged_as_alice(101) + end + + it "is served with sender_verified false when posted with the name-derived id" do + post_message(channel_message(102, alice_derived_id, "Alice: unrostered")) + expect_flagged_as_alice(102) + end + + it "is served with sender_verified false when posted with a positively stale same-name key" do + seed_alice("!a11ce0ff", "af", now - 40 * day) + post_message(channel_message(103, "!a11ce0ff", "Alice: via a stale roster")) + expect_flagged_as_alice(103) + end + end + + describe "a line whose sender comes from an id the packet carries" do + it "a Meshtastic message has no sender_verified key on either route" do + post_message( + "id" => 201, "rx_time" => now - 60, "from_id" => "!0badc0de", "to_id" => "^all", + "channel" => 0, "text" => "Alice: hello", "portnum" => "TEXT_MESSAGE_APP", "protocol" => "meshtastic", + ) + [served_row("/api/messages", 201), served_row("/api/messages/!0badc0de", 201)].each do |row| + expect(row).to include("node_id" => "!0badc0de") + expect(row).not_to have_key("sender_verified") + end + end + + it "a MeshCore direct message has no sender_verified key on either route" do + seed_alice(alice, "a1", now - 3 * day) + post_message(channel_message(202, alice, "hello from my own key").merge("to_id" => host).except("channel_name")) + [served_row("/api/messages", 202), served_row("/api/messages/#{alice}", 202)].each do |row| + expect(row).to include("node_id" => alice, "to_id" => host) + expect(row).not_to have_key("sender_verified") + end + end + end + + describe "a MeshCore channel line without a sender" do + it "has no sender_verified key" do + post_message(channel_message(301, nil, "no sender prefix")) + row = served_row("/api/messages", 301) + expect(row).not_to have_key("from_id") + expect(row).not_to have_key("sender_verified") + end + end + + describe "#meshcore_sender_name_attributed?" do + let(:channel_row) { { "protocol" => "meshcore", "to_id" => "^all", "from_id" => alice } } + + it "is true for a MeshCore channel row with a sender" do + expect(dp.meshcore_sender_name_attributed?(channel_row)).to be(true) + end + + { + "another protocol" => { "protocol" => "meshtastic" }, + "no protocol" => { "protocol" => nil }, + "a direct message" => { "to_id" => "!634069bc" }, + "no recipient" => { "to_id" => nil }, + "no sender" => { "from_id" => nil }, + "a blank sender" => { "from_id" => " " }, + }.each do |label, overrides| + it "is false for #{label}" do + expect(dp.meshcore_sender_name_attributed?(channel_row.merge(overrides))).to be(false) + end + end + end +end