Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion capsules/chat-room-ui/.chat_room_ui_source.sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
6654816bf80d5e89d51a00a92dc40d97ffc85ddb114119e5f7888008cc6c58bc
a721c6199fb3627537a544c7e1e7f7cd16739297aadeb97ec0b8197c23402596
39 changes: 34 additions & 5 deletions capsules/chat-room-ui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2380,7 +2380,7 @@ impl App {
self.handle_session_loss(self.session_loss_detail());
return Ok(false);
}
Err(err) => return Err(err),
Err(err) => return Err(room_send_error_text(err)),
};

let mut state = self.state.borrow_mut();
Expand Down Expand Up @@ -4025,10 +4025,10 @@ mod tests {
conversation_initial, current_selection_guard, decode_query_value, extract_fragment_param,
extract_query_param, format_chat_message_request_id, object_sender_name,
participant_detail, participant_shown_name, pending_chat_request_id, render_projection,
resolve_conversation_choice, selection_guard_matches, shell_summary_allows_session,
AccessMode, AppConfig, AppState, ConversationObjectKind, ConversationObjectView,
ParticipantView, PendingChatSend, RenderProjection, RoomPollView, RoomTransportView,
ShellSessionBootstrapFailure, ShellSessionStartOutput, SummaryView,
resolve_conversation_choice, room_send_error_text, selection_guard_matches,
shell_summary_allows_session, AccessMode, AppConfig, AppState, ConversationObjectKind,
ConversationObjectView, ParticipantView, PendingChatSend, RenderProjection, RoomPollView,
RoomTransportView, ShellSessionBootstrapFailure, ShellSessionStartOutput, SummaryView,
};

#[test]
Expand Down Expand Up @@ -4149,6 +4149,25 @@ mod tests {
));
}

#[test]
fn room_send_rate_limit_reads_as_slow_down() {
assert_eq!(
room_send_error_text(
"request failed: 429 Slow down. You can send another message in 4 seconds."
.to_string()
),
"Slow down. You can send another message in 4 seconds."
);
assert_eq!(
room_send_error_text("request failed: 429 ".to_string()),
"Slow down. Try again in a few seconds."
);
assert_eq!(
room_send_error_text("request failed: 500 boom".to_string()),
"request failed: 500 boom"
);
}

#[test]
fn shell_session_bootstrap_errors_are_typed_and_bounded() {
assert_eq!(
Expand Down Expand Up @@ -4836,6 +4855,16 @@ where
Ok(request_id)
}

/// Shows the Home's "Slow down" refusal as written. The draft stays in the
/// composer, so sending again later delivers it.
fn room_send_error_text(err: String) -> String {
match err.strip_prefix("request failed: 429 ") {
Some(detail) if !detail.trim().is_empty() => detail.trim().to_string(),
Some(_) => "Slow down. Try again in a few seconds.".to_string(),
None => err,
}
}

fn is_session_error(err: &str) -> bool {
err.contains("invalid or expired session") || err.contains("request failed: 401")
}
Expand Down
4 changes: 2 additions & 2 deletions capsules/chat-room/browser/chat-room.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import {
createHomeClipboardClient,
} from "/apps/home/home-clipboard-client.js?v=home-20260726a";
import init from "./chat_room_ui.js?v=chat-room-ui-20260804a";
import init from "./chat_room_ui.js?v=chat-room-ui-20261008a";

const chatLaunchParams = new URLSearchParams(window.location.search);
const chatHomeToken = new URLSearchParams(window.location.hash.replace(/^#/, "")).get("home_token") || "";
Expand All @@ -20,6 +20,6 @@
purpose: "conversation.invite",
});
init({
module_or_path: new URL("./chat_room_ui_bg.wasm?v=chat-room-ui-20260804a", import.meta.url),
module_or_path: new URL("./chat_room_ui_bg.wasm?v=chat-room-ui-20261008a", import.meta.url),
});
}
8 changes: 4 additions & 4 deletions capsules/chat-room/browser/chat_room_ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ export function start() {
wasm.start();
}

function wasm_bindgen__convert__closures_____invoke__h76bc78299e8b98a7(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h76bc78299e8b98a7(arg0, arg1);
}

function wasm_bindgen__convert__closures_____invoke__h6c882d24848f50e5(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h6c882d24848f50e5(arg0, arg1, arg2);
}
Expand All @@ -230,10 +234,6 @@ function wasm_bindgen__convert__closures_____invoke__h31c454d25a03d65b(arg0, arg
wasm.wasm_bindgen__convert__closures_____invoke__h31c454d25a03d65b(arg0, arg1);
}

function wasm_bindgen__convert__closures_____invoke__h76bc78299e8b98a7(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h76bc78299e8b98a7(arg0, arg1);
}

const __wbindgen_enum_RequestCredentials = ["omit", "same-origin", "include"];

const EXPECTED_RESPONSE_TYPES = new Set(['basic', 'cors', 'default']);
Expand Down
Binary file modified capsules/chat-room/browser/chat_room_ui_bg.wasm
Binary file not shown.
26 changes: 26 additions & 0 deletions docs/PEOPLE_CONVERSATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,32 @@ offline, and neither invents a third party to hide it:
deliberate bound, not a shared constant with the 64-head contact store: the
ring bounds announcement payloads, the head store bounds relationships.

## Spam Limits

Each limit is a constant in the Runtime and refuses only the extra item:

- Community messages: a Home sends at most 5 messages per Profile in any
10 seconds. The sixth gets HTTP 429 with `Retry-After`, Chat shows "Slow
down", and the draft stays in the composer. A retry of an already accepted
message is free; the count comes from saved messages, so a send that fails
to save does not count. Every receiving Home also accepts at most 5
messages per sender Profile in 10 seconds, and keeps at most 8 per sender
waiting for Chat. Past either limit it holds that sender's message and
retries it itself, since the sender stops resending once any other Home
accepts it; other senders' messages in the same batch keep flowing. Network
delay can bunch an honest sender's messages into one window; the Home holds
them and lets them in as the window slides. A Home holds at most 5 messages
per sender, and drops a sender's flood beyond that. When its 64 held
messages are full, the sender holding the most gives up its newest one first; if every sender holds as few, the Home waits and
retries the batch instead of dropping a message. Receive windows and held
messages live in memory and reset on restart.
- Discovery relay: a Home's Carrier endpoint is its signing device, and the
relay accepts a listing or contact request only from the device that signed
it, so another Home cannot replay it. One Home lists at most 4 people in
`Visible now`; renewing a stored listing is free. The relay stores at most 8
contact requests per sender Profile and 16 per Home, and refuses a call
without an authenticated Carrier endpoint.

## Near-Term Non-Goals

- Attachments in direct conversations. Deferred by decision, not omission:
Expand Down
8 changes: 8 additions & 0 deletions elastos/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ release's Added, Changed, Fixed, Removed and Security bullets before you update;

## [Unreleased]

### Changed

- Spam limits for Chat: 5 Community messages per Profile every 10 seconds (Chat
shows "Slow down"), 5 per sender on receiving Homes without holding up other
senders, and Discovery relay caps of 4 listings per Home, 8 contact requests
per sender and 16 per Home. The relay admits only the signing Home's own
submissions.

## [0.8.0-alpha.10]

Test-channel release. Signed releases show these notes in System before you update.
Expand Down
10 changes: 10 additions & 0 deletions elastos/crates/elastos-server/src/api/gateway_room.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1799,6 +1799,16 @@ fn upload_offset_from_headers(headers: &HeaderMap) -> anyhow::Result<u64> {
}

pub(super) fn room_service_error_response(err: anyhow::Error) -> Response {
if let Some(limited) =
err.downcast_ref::<crate::collaboration_rate_limit::CommunitySendRateLimited>()
{
return (
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", limited.retry_after_secs.to_string())],
limited.to_string(),
)
.into_response();
}
let profile_required = gateway_home_system::profile_required_message(&err);
let text = profile_required
.map(str::to_string)
Expand Down
64 changes: 64 additions & 0 deletions elastos/crates/elastos-server/src/api/gateway_tests/room.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2382,3 +2382,67 @@ async fn test_room_service_session_leave_appends_system_object() {
let summary = crate::room_service::load_summary(dir.path()).unwrap();
assert_eq!(summary.active_session_count, 0);
}

#[tokio::test]
async fn test_chat_room_configured_send_asks_a_fast_sender_to_slow_down() {
let dir = tempfile::tempdir().unwrap();
let authority = passkey_authority_with_profile(dir.path(), "owner");
let port = crate::collaboration_product::test_chat_product_port(
dir.path(),
"rate-network",
"rate-conversation",
);
let mut state = test_state(dir.path());
state.collaboration_chat_product_port = Some(port.clone());
let app = gateway_router(state);
let token =
projection_launch_token_for_authority_context(dir.path(), CHAT_ROOM_CAPSULE_ID, &authority);
let send = |index: usize| {
test_browser_request("localhost:61180", "null")
.method("POST")
.uri("/api/apps/chat-room/objects/send")
.header("x-elastos-home-token", token.as_str())
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"request_id": format!("chat-message:{index:032x}"),
"body": format!("message {index}"),
})
.to_string(),
))
.unwrap()
};
for index in 0..crate::collaboration_rate_limit::COMMUNITY_SENDS_PER_WINDOW {
let response = app.clone().oneshot(send(index)).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}

let limited = app
.clone()
.oneshot(send(
crate::collaboration_rate_limit::COMMUNITY_SENDS_PER_WINDOW,
))
.await
.unwrap();
assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
let retry_after: u64 = limited.headers()["retry-after"]
.to_str()
.unwrap()
.parse()
.unwrap();
assert!(
(1..=crate::collaboration_rate_limit::COMMUNITY_RATE_WINDOW_SECS).contains(&retry_after)
);
let body = axum::body::to_bytes(limited.into_body(), usize::MAX)
.await
.unwrap();
assert!(String::from_utf8_lossy(&body).starts_with("Slow down."));
assert_eq!(
port.test_live_unresolved_outgoing().unwrap(),
crate::collaboration_rate_limit::COMMUNITY_SENDS_PER_WINDOW
);

// Resending a delivered message is not a new send.
let replay = app.clone().oneshot(send(0)).await.unwrap();
assert_eq!(replay.status(), StatusCode::OK);
}
Loading
Loading