Repository: zulip/zulip
Pull request #34152: message_edit: Check previous message content to prevent races.
Author: login: kuv2707
Labels: name: size: XL
Created: 2025-03-25T06:32:30Z | Merged: 2025-04-16T21:05:12Z

Similar to group based setting values, we expect
the client to send the previous content alongwith
the edited content to the edit message endpoint.
We reject the request incase the previous content
doesn't match the current message content, which
could happen in case two users simultaneously edit a message
 (to be implemented in #33051).

This PR is a prep to #33051.

<!-- Describe your pull request here.-->


<!-- If the PR makes UI changes, always include one or more still screenshots to demonstrate your changes. If it seems helpful, add a screen capture of the new functionality as well.

Tooling tips: https://zulip.readthedocs.io/en/latest/tutorials/screenshot-and-gif-software.html
-->

**Screenshots and screen captures:**
*Intentionally disabling the message edit event so that two clients are no longer synced, then trying to edit the message*

![image](https://github.com/user-attachments/assets/a0337723-76bd-419f-87eb-c8e18d7f2af4)


<details>
<summary>Self-review checklist</summary>

<!-- Prior to submitting a PR, follow our step-by-step guide to review your own code:
https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#how-to-review-code -->

<!-- Once you create the PR, check off all the steps below that you have completed.
If any of these steps are not relevant or you have not completed, leave them unchecked.-->

- [x] [Self-reviewed](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#how-to-review-code) the changes for clarity and maintainability
      (variable names, code reuse, readability, etc.).

Communicate decisions, questions, and potential concerns.

- [ ] Explains differences from previous plans (e.g., issue description).
- [ ] Highlights technical choices and bugs encountered.
- [ ] Calls out remaining decisions and concerns.
- [ ] Automated tests verify logic where appropriate.

Individual commits are ready for review (see [commit discipline](https://zulip.readthedocs.io/en/latest/contributing/commit-discipline.html)).

- [ ] Each commit is a coherent idea.
- [ ] Commit message(s) explain reasoning and motivation for changes.

Completed manual review and testing of the following:

- [x] Visual appearance of the changes.
- [x] Responsiveness and internationalization.
- [ ] Strings and tooltips.
- [ ] End-to-end functionality of buttons, interactions and flows.
- [ ] Corner cases, error conditions, and easily imagined bugs.
</details>



Diff:
diff --git a/api_docs/changelog.md b/api_docs/changelog.md
index fca488703de46..87d219df59ffd 100644
--- a/api_docs/changelog.md
+++ b/api_docs/changelog.md
@@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with.
 
 ## Changes in Zulip 11.0
 
+**Feature level 379**
+
+* [`PATCH /messages/{message_id}`](/api/update-message): Added
+ optional parameter `prev_content_sha256`, which clients can use to
+ prevent races with the message being edited by another client.
+
 **Feature level 378**
 
 * [`GET /events`](/api/get-events): Archiving and unarchiving
diff --git a/web/src/click_handlers.ts b/web/src/click_handlers.ts
index 0b8ae60b28c8b..c6accba69e1f8 100644
--- a/web/src/click_handlers.ts
+++ b/web/src/click_handlers.ts
@@ -341,7 +341,7 @@ export function initialize(): void {
     });
     $("body").on("click", ".message_edit_save", function (e) {
         const $row = $(this).closest(".message_row");
-        message_edit.save_message_row_edit($row);
+        void message_edit.save_message_row_edit($row);
         e.stopPropagation();
     });
     $("body").on("click", ".message_edit_cancel", function (e) {
diff --git a/web/src/message_edit.ts b/web/src/message_edit.ts
index c75e84059d1d6..c1298619b0a32 100644
--- a/web/src/message_edit.ts
+++ b/web/src/message_edit.ts
@@ -414,7 +414,7 @@ function handle_message_edit_enter(
             compose_validate.validate_message_length($row);
             return;
         }
-        save_message_row_edit($row);
+        void save_message_row_edit($row);
         e.stopPropagation();
         e.preventDefault();
     } else {
@@ -1142,7 +1142,6 @@ export function do_save_inline_topic_edit($row: JQuery, message: Message, new_to
     }
 
     const request = {
-        message_id: message.id,
         topic: new_topic,
         propagate_mode: "change_all",
         send_notification_to_old_thread: false,
@@ -1197,7 +1196,7 @@ export function do_save_inline_topic_edit($row: JQuery, message: Message, new_to
     });
 }
 
-export function save_message_row_edit($row: JQuery): void {
+export async function save_message_row_edit($row: JQuery): Promise<void> {
     compose_tooltips.hide_compose_control_button_tooltips($row);
 
     assert(message_lists.current !== undefined);
@@ -1218,6 +1217,7 @@ export function save_message_row_edit($row: JQuery): void {
 
     let new_content;
     const old_content = message.raw_content;
+    assert(old_content !== undefined);
 
     const $edit_content_input = $row.find<HTMLTextAreaElement>("textarea.message_edit_content");
     const can_edit_content = $edit_content_input.attr("readonly") !== "readonly";
@@ -1263,7 +1263,10 @@ export function save_message_row_edit($row: JQuery): void {
         return;
     }
 
-    const request = {message_id: message.id, content: new_content};
+    const request = {
+        content: new_content,
+        prev_content_sha256: await util.sha256_hash(old_content),
+    };
 
     if (!markdown.contains_backend_only_syntax(new_content ?? "")) {
         // If the new message content could have been locally echoed,
@@ -1362,6 +1365,17 @@ export function save_message_row_edit($row: JQuery): void {
                                 $container,
                             );
                             return;
+                        } else if (code === "EXPECTATION_MISMATCH") {
+                            const message = $t({
+                                defaultMessage:
+                                    "Error editing message: Message was edited by another client.",
+                            });
+                            compose_banner.show_error_message(
+                                message,
+                                compose_banner.CLASSNAMES.generic_compose_error,
+                                $container,
+                            );
+                            return;
                         }
                     }
 
diff --git a/web/src/util.ts b/web/src/util.ts
index 5151657a8aad4..db707594de5f0 100644
--- a/web/src/util.ts
+++ b/web/src/util.ts
@@ -601,3 +601,17 @@ export function get_retry_backoff_seconds(
     }
     return Math.max(backoff_delay_secs, rate_limit_delay_secs);
 }
+
+export async function sha256_hash(text: string): Promise<string | undefined> {
+    // The Web Crypto API is only available in secure contexts (HTTPS or localhost).
+    if (!window.isSecureContext) {
+        return undefined;
+    }
+
+    const encoder = new TextEncoder();
+    const data = encoder.encode(text);
+    const hashBuffer = await crypto.subtle.digest("SHA-256", data);
+    const hashArray = [...new Uint8Array(hashBuffer)];
+    const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
+    return hashHex;
+}
diff --git a/web/tests/util.test.cjs b/web/tests/util.test.cjs
index 5df8fceb3436a..5b65c40e5a80e 100644
--- a/web/tests/util.test.cjs
+++ b/web/tests/util.test.cjs
@@ -589,3 +589,13 @@ run_test("get_retry_backoff_seconds", () => {
     assert.ok(backoff >= 45);
     assert.ok(backoff <= 90);
 });
+
+run_test("sha256_hash", async ({override}) => {
+    const expected_hash = "f8e27cb511cd469712e3e0f2ac05a990481c0a39e11830b4f6aee729a894b769";
+    const data = "@*hamlet_and_cordelia* and #**channel>topic**";
+    let hash = await util.sha256_hash(data);
+    assert.equal(hash, undefined);
+    override(window, "isSecureContext", true);
+    hash = await util.sha256_hash(data);
+    assert.equal(hash, expected_hash);
+});
diff --git a/zerver/actions/message_edit.py b/zerver/actions/message_edit.py
index 32acdd385ce7d..1f6f4aae871d3 100644
--- a/zerver/actions/message_edit.py
+++ b/zerver/actions/message_edit.py
@@ -25,9 +25,11 @@
 )
 from zerver.actions.uploads import AttachmentChangeResult, check_attachment_reference_change
 from zerver.actions.user_topics import bulk_do_set_user_topic_visibility_policy
+from zerver.lib import utils
 from zerver.lib.exceptions import (
     JsonableError,
     MessageMoveError,
+    PreviousMessageContentMismatchedError,
     StreamWildcardMentionNotAllowedError,
     TopicWildcardMentionNotAllowedError,
 )
@@ -107,9 +109,10 @@ def validate_message_edit_payload(
     topic_name: str | None,
     propagate_mode: str | None,
     content: str | None,
+    prev_content_sha256: str | None,
 ) -> None:
     """
-    Checks that the data sent is well-formed. Does not handle editability, permissions etc.
+    Validates that a message edit request is well-formed. Does not handle permissions.
     """
     if topic_name is None and content is None and stream_id is None:
         raise JsonableError(_("Nothing to change"))
@@ -142,6 +145,14 @@ def validate_message_edit_payload(
     if content is not None and is_widget_message(message):
         raise JsonableError(_("Widgets cannot be edited."))
 
+    # We don't restrict this check to requests to edit content, to
+    # give the parameter a pure meaning. But clients sending this for
+    # topic moves are likely doing so in error.
+    if prev_content_sha256 is not None and prev_content_sha256 != utils.sha256_hash(
+        message.content
+    ):
+        raise PreviousMessageContentMismatchedError
+
 
 def validate_user_can_edit_message(
     user_profile: UserProfile, message: Message, edit_limit_buffer: int
@@ -1370,6 +1381,7 @@ def check_update_message(
     send_notification_to_old_thread: bool = True,
     send_notification_to_new_thread: bool = True,
     content: str | None = None,
+    prev_content_sha256: str | None = None,
 ) -> UpdateMessageResult:
     """This will update a message given the message id and user profile.
     It checks whether the user profile has the permission to edit the message
@@ -1397,7 +1409,9 @@ def check_update_message(
         if topic_name == message.topic_name():
             topic_name = None
 
-    validate_message_edit_payload(message, stream_id, topic_name, propagate_mode, content)
+    validate_message_edit_payload(
+        message, stream_id, topic_name, propagate_mode, content, prev_content_sha256
+    )
 
     message_edit_request = build_message_edit_request(
         message=message,
diff --git a/zerver/lib/exceptions.py b/zerver/lib/exceptions.py
index 38bcf8947aac6..0c154d045929f 100644
--- a/zerver/lib/exceptions.py
+++ b/zerver/lib/exceptions.py
@@ -684,16 +684,29 @@ def msg_format() -> str:
         return _("You do not have permission to use topic wildcard mentions in this topic.")
 
 
-class PreviousSettingValueMismatchedError(JsonableError):
+class ExpectationMismatchError(JsonableError):
     code: ErrorCode = ErrorCode.EXPECTATION_MISMATCH
+    data_fields = ["field_name"]
 
     def __init__(self) -> None:
-        pass
+        self.field_name = "field"
 
     @staticmethod
     @override
     def msg_format() -> str:
-        return _("'old' value does not match the expected value.")
+        return _("'{field_name}' value does not match the expected value.")
+
+
+class PreviousSettingValueMismatchedError(ExpectationMismatchError):
+    def __init__(self) -> None:
+        super().__init__()
+        self.field_name = "old"
+
+
+class PreviousMessageContentMismatchedError(ExpectationMismatchError):
+    def __init__(self) -> None:
+        super().__init__()
+        self.field_name = "prev_content_sha256"
 
 
 class SystemGroupRequiredError(JsonableError):
diff --git a/zerver/lib/utils.py b/zerver/lib/utils.py
index 3d27b68be8e00..326922c103ac0 100644
--- a/zerver/lib/utils.py
+++ b/zerver/lib/utils.py
@@ -1,3 +1,4 @@
+import hashlib
 import re
 import secrets
 from collections.abc import Callable
@@ -68,3 +69,7 @@ def get_fk_field_name(model: type[models.Model], related_model: type[models.Mode
     assert len(foreign_key_fields_to_related_model) == 1
 
     return foreign_key_fields_to_related_model[0].name
+
+
+def sha256_hash(text: str) -> str:
+    return hashlib.sha256(text.encode()).hexdigest()
diff --git a/zerver/openapi/python_examples.py b/zerver/openapi/python_examples.py
index 078053be186d4..d4fb93a17bf7a 100644
--- a/zerver/openapi/python_examples.py
+++ b/zerver/openapi/python_examples.py
@@ -1195,7 +1195,7 @@ def delete_saved_snippet(client: Client, saved_snippet_id: int) -> None:
 
 
 @openapi_test_function("/messages:post")
-def send_message(client: Client) -> int:
+def send_message(client: Client) -> tuple[int, str]:
     request: dict[str, Any] = {}
     # {code_example|start}
     # Send a channel message.
@@ -1231,7 +1231,7 @@ def send_message(client: Client) -> int:
     # Confirm the message was actually sent.
     message_id = result["id"]
     validate_message(client, message_id, request["content"])
-    return message_id
+    return message_id, request["content"]
 
 
 @openapi_test_function("/messages/{message_id}/reactions:post")
@@ -1298,7 +1298,11 @@ def test_private_message_invalid_recipient(client: Client) -> None:
 
 
 @openapi_test_function("/messages/{message_id}:patch")
-def update_message(client: Client, message_id: int) -> None:
+def update_message(client: Client, message_id: int, prev_content: str) -> None:
+    # We elect not to pass prev_content_sha256, because at present, it
+    # is likely to be experienced as clutter for almost all end users
+    # of this API.
+    #
     # {code_example|start}
     # Edit a message. Make sure that `message_id` is set to the ID of the
     # message you wish to update.
@@ -1799,11 +1803,11 @@ def test_invalid_stream_error(client: Client) -> None:
 
 def test_messages(client: Client, nonadmin_client: Client) -> None:
     render_message(client)
-    message_id = send_message(client)
+    message_id, content = send_message(client)
     set_message_edit_typing_status(client, message_id)
     add_reaction(client, message_id)
     remove_reaction(client, message_id)
-    update_message(client, message_id)
+    update_message(client, message_id, content)
     get_raw_message(client, message_id)
     get_messages(client)
     check_messages_match_narrow(client)
diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml
index 7c417419fea5a..71e480a3e30d2 100644
--- a/zerver/openapi/zulip.yaml
+++ b/zerver/openapi/zulip.yaml
@@ -9002,6 +9002,20 @@ paths:
                   example: true
                 content:
                   $ref: "#/components/schemas/OptionalContent"
+                prev_content_sha256:
+                  description: |
+                    An optional SHA-256 hash of the previous raw content of the message
+                    that the client has at the time of the request.
+
+                    If provided, the server will return an error if it does not match the
+                    SHA-256 hash of the message's content stored in the database.
+
+                    Clients can use this feature to prevent races where multiple clients
+                    save conflicting edits to a message.
+
+                    **Changes**: New in Zulip 11.0 (feature level 379).
+                  type: string
+                  example: "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72" # "test content"
                 stream_id:
                   description: |
                     The channel ID to move the message(s) to, to request moving
diff --git a/zerver/tests/test_message_edit.py b/zerver/tests/test_message_edit.py
index aa574dd3b26fb..62e6d3b35f565 100644
--- a/zerver/tests/test_message_edit.py
+++ b/zerver/tests/test_message_edit.py
@@ -15,6 +15,7 @@
 from zerver.actions.streams import do_change_stream_group_based_setting, do_deactivate_stream
 from zerver.actions.user_groups import add_subgroups_to_user_group, check_add_user_group
 from zerver.actions.user_topics import do_set_user_topic_visibility_policy
+from zerver.lib import utils
 from zerver.lib.message import messages_for_ids
 from zerver.lib.message_cache import MessageDict
 from zerver.lib.test_classes import ZulipTestCase
@@ -2257,3 +2258,43 @@ def test_remove_another_user_deleted_attachment_while_editing(self) -> None:
         result_content = orjson.loads(result.content)
         self.assertEqual(result_content["result"], "success")
         self.assert_length(result_content["detached_uploads"], 0)
+
+    def test_edit_message_race_condition(self) -> None:
+        # If two users try to edit the same message at the same time,
+        # one of them should get an error message, as the `prev_content`
+        # parameter passed to the PATCH call would be outdated and not
+        # match.
+
+        # If `prev_content` does not match the expected value, this means
+        # the edit request is stale and should be rejected. We simulate
+        # that here.
+        self.login("hamlet")
+        msg_id = self.send_stream_message(
+            self.example_user("hamlet"),
+            "Denmark",
+            topic_name="editing",
+            content="Init message",
+        )
+        init_msg = utils.sha256_hash("Init message")
+        result = self.client_patch(
+            f"/json/messages/{msg_id}",
+            {
+                "content": "First user edit",
+                "prev_content_sha256": init_msg,
+            },
+        )
+        self.assert_json_success(result)
+        self.check_message(msg_id, topic_name="editing", content="First user edit")
+
+        result = self.client_patch(
+            f"/json/messages/{msg_id}",
+            {
+                "content": "Second user edit",
+                "prev_content_sha256": init_msg,
+            },
+        )
+        self.assert_json_error(
+            result,
+            "'prev_content_sha256' value does not match the expected value.",
+        )
+        self.check_message(msg_id, topic_name="editing", content="First user edit")
diff --git a/zerver/views/message_edit.py b/zerver/views/message_edit.py
index 9889ad092563f..102c25073cd3d 100644
--- a/zerver/views/message_edit.py
+++ b/zerver/views/message_edit.py
@@ -153,6 +153,7 @@ def update_message_backend(
     send_notification_to_old_thread: Json[bool] = False,
     send_notification_to_new_thread: Json[bool] = True,
     content: str | None = None,
+    prev_content_sha256: str | None = None,
 ) -> HttpResponse:
     updated_message_result = check_update_message(
         user_profile,
@@ -163,6 +164,7 @@ def update_message_backend(
         send_notification_to_old_thread,
         send_notification_to_new_thread,
         content,
+        prev_content_sha256,
     )
 
     # Include the number of messages changed in the logs


Reviews:
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T19:41:36Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T19:42:39Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T19:43:10Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T19:45:27Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T19:47:29Z
user: login: kuv2707
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T18:09:27Z
user: login: kuv2707
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T18:09:30Z
user: login: kuv2707
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T18:09:35Z
user: login: kuv2707
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T18:09:40Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T00:48:04Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T00:49:11Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T00:51:59Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T01:02:00Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T01:04:22Z
user: login: kuv2707
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T01:51:01Z
user: login: kuv2707
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T14:53:32Z
user: login: kuv2707
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T14:58:33Z
user: login: kuv2707
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T15:00:04Z
user: login: gnprice
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T05:32:19Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T20:13:05Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T20:14:32Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T20:15:28Z

Review comments:
diff_hunk: @@ -8966,6 +8966,18 @@ paths:
                   example: true
                 content:
                   $ref: "#/components/schemas/OptionalContent"
+                prev_content:
+                  description: |
+                    "The previous raw content of the message that the client
+                    has at the time of the request. It is used to check for
+                    races when many users are editing the same messages.
+                    If it doesn't match the current content of the message
+                    in the database, the edit request is rejected.
+
+                    **Changes**: New in Zulip 11.0 (feature level 376).
+                    "
path: zerver/openapi/zulip.yaml
user: login: timabbott
body: Remove these quotes. They're implied by the YAML `|` structure. You should see this if you view the generated API docs. 


created_at: 2025-03-25T19:41:36Z
author_association: MEMBER
diff_hunk: @@ -20,6 +20,13 @@ format used by the Zulip server that they are interacting with.
 
 ## Changes in Zulip 11.0
 
+**Feature level 376**
+
+* [`PATCH /messages/{message_id}`](/api/update-message): Added
+ optional parameter `prev_content`, which is used to check the
+ client side version of the message with the one in db before
+ updating the message content.
path: api_docs/changelog.md
user: login: timabbott
body: There's a new mechanism involving `tools/create-api-changelog` and `tools/merge-api-changelogs` for API changes without dealing with renumbering. 

Though maybe for the first API change of a release we want to do it this way anyway. 
created_at: 2025-03-25T19:42:39Z
author_association: MEMBER
diff_hunk: @@ -1096,7 +1102,7 @@ def exclude_duplicates_from_subscription(
                     visibility_policy=new_visibility_policy,
                 )
 
-    send_event_on_commit(user_profile.realm, event, users_to_be_notified)
+    # send_event_on_commit(user_profile.realm, event, users_to_be_notified)
path: zerver/actions/message_edit.py
user: login: timabbott
body: Don't forget to check your code for testing tweaks like this :)
created_at: 2025-03-25T19:43:10Z
author_association: MEMBER
diff_hunk: @@ -694,6 +694,17 @@ def __init__(self) -> None:
     @override
     def msg_format() -> str:
         return _("'old' value does not match the expected value.")
+    
+class PreviousMessageContentMismatchedError(JsonableError):
+    code: ErrorCode = ErrorCode.EXPECTATION_MISMATCH
+
+    def __init__(self) -> None:
+        pass
+
+    @staticmethod
+    @override
+    def msg_format() -> str:
+        return _("Old message content does not match the expected content.")
path: zerver/lib/exceptions.py
user: login: timabbott
body: I think we want to have this share a common base class and translated string template with `PreviousSettingValueMismatchedError`. Probably we should just make the `'old'` variable in that string be a parameter, so `_("'{field_name}' value does not match the expected value.", field_name="old")` would be what the existing code did. Might be cleanest done as a prep commit.
created_at: 2025-03-25T19:45:26Z
author_association: MEMBER
diff_hunk: @@ -1261,7 +1261,7 @@ export function save_message_row_edit($row: JQuery): void {
         return;
     }
 
-    const request = {message_id: message.id, content: new_content};
+    const request = {message_id: message.id, content: new_content, prev_content: old_content};
path: web/src/message_edit.ts
user: login: timabbott
body: We'll also want to have a commit extending the `error` handler to handle the `expectation_mismatch` exception. Probably a good v1 would be to just display an error string of the form "Message was edited by another client.", but we'll need to discuss the precise answer in #design.
created_at: 2025-03-25T19:47:29Z
author_association: MEMBER
diff_hunk: @@ -694,6 +694,17 @@ def __init__(self) -> None:
     @override
     def msg_format() -> str:
         return _("'old' value does not match the expected value.")
+    
+class PreviousMessageContentMismatchedError(JsonableError):
+    code: ErrorCode = ErrorCode.EXPECTATION_MISMATCH
+
+    def __init__(self) -> None:
+        pass
+
+    @staticmethod
+    @override
+    def msg_format() -> str:
+        return _("Old message content does not match the expected content.")
path: zerver/lib/exceptions.py
user: login: kuv2707
body: Added a commit
created_at: 2025-03-26T18:09:27Z
author_association: COLLABORATOR
diff_hunk: @@ -1261,7 +1261,7 @@ export function save_message_row_edit($row: JQuery): void {
         return;
     }
 
-    const request = {message_id: message.id, content: new_content};
+    const request = {message_id: message.id, content: new_content, prev_content: old_content};
path: web/src/message_edit.ts
user: login: kuv2707
body: I've changed it to using this string for now.
created_at: 2025-03-26T18:09:30Z
author_association: COLLABORATOR
diff_hunk: @@ -1096,7 +1102,7 @@ def exclude_duplicates_from_subscription(
                     visibility_policy=new_visibility_policy,
                 )
 
-    send_event_on_commit(user_profile.realm, event, users_to_be_notified)
+    # send_event_on_commit(user_profile.realm, event, users_to_be_notified)
path: zerver/actions/message_edit.py
user: login: kuv2707
body: Oh, removed this change.
created_at: 2025-03-26T18:09:34Z
author_association: COLLABORATOR
diff_hunk: @@ -8966,6 +8966,18 @@ paths:
                   example: true
                 content:
                   $ref: "#/components/schemas/OptionalContent"
+                prev_content:
+                  description: |
+                    "The previous raw content of the message that the client
+                    has at the time of the request. It is used to check for
+                    races when many users are editing the same messages.
+                    If it doesn't match the current content of the message
+                    in the database, the edit request is rejected.
+
+                    **Changes**: New in Zulip 11.0 (feature level 376).
+                    "
path: zerver/openapi/zulip.yaml
user: login: kuv2707
body: Oh, this was added accidentally. 
Removed.
created_at: 2025-03-26T18:09:40Z
author_association: COLLABORATOR
diff_hunk: @@ -601,3 +601,16 @@ export function get_retry_backoff_seconds(
     }
     return Math.max(backoff_delay_secs, rate_limit_delay_secs);
 }
+
+export async function sha256_hash(text: string): Promise<string | undefined> {
+    // The Web Crypto API is only available in secure contexts (HTTPS).
+    if (!window.isSecureContext) {
+        return undefined;
+    }
path: web/src/util.ts
user: login: timabbott
body: How did you test this code? In a development environment, we're not a secure context?
created_at: 2025-04-08T00:48:04Z
author_association: MEMBER
diff_hunk: @@ -333,11 +333,19 @@ add_example("update_message", "/messages/{message_id}:patch", 200, async (client
     const result = await client.messages.send(request);
     const message_id = result.id;
 
+    async function sha256_hash(text) {
+        const msgUint8 = new TextEncoder().encode(text);
+        const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8);
+        const hashArray = new Uint8Array(hashBuffer);
+        return hashArray.toString();
+    }
+
     // {code_example|start}
     // Update a message with the given "message_id"
     const params = {
         message_id,
         content: "New Content",
+        prev_content: sha256_hash("I come not, friends, to steal away your hearts."),
path: zerver/openapi/javascript_examples.js
user: login: timabbott
body: Probably this should be framed as optional via a comment.
created_at: 2025-04-08T00:49:11Z
author_association: MEMBER
diff_hunk: @@ -1263,8 +1263,11 @@ export function save_message_row_edit($row: JQuery): void {
         return;
     }
 
-    const request = {message_id: message.id, content: new_content};
-
+    const request = {
+        message_id: message.id,
+        content: new_content,
+        prev_content_sha256: await util.sha256_hash(old_content ?? ""),
path: web/src/message_edit.ts
user: login: timabbott
body: I don't think we want `""` in here -- we should always have it.
created_at: 2025-04-08T00:51:59Z
author_association: MEMBER
diff_hunk: @@ -1263,8 +1263,11 @@ export function save_message_row_edit($row: JQuery): void {
         return;
     }
 
-    const request = {message_id: message.id, content: new_content};
-
+    const request = {
+        message_id: message.id,
+        content: new_content,
+        prev_content_sha256: await util.sha256_hash(old_content ?? ""),
path: web/src/message_edit.ts
user: login: timabbott
body: Probably reflects a missing assert when `old_content` is defined earlier.
created_at: 2025-04-08T01:02:00Z
author_association: MEMBER
diff_hunk: @@ -601,3 +601,16 @@ export function get_retry_backoff_seconds(
     }
     return Math.max(backoff_delay_secs, rate_limit_delay_secs);
 }
+
+export async function sha256_hash(text: string): Promise<string | undefined> {
+    // The Web Crypto API is only available in secure contexts (HTTPS).
+    if (!window.isSecureContext) {
+        return undefined;
+    }
path: web/src/util.ts
user: login: timabbott
body: I think localhost is one -- so maybe this could just be an assert?

(It'd be reasonable for the message edit caller to skip the call if it's undefined -- but I'd prefer this util function throw an exception if called when it can't work).
created_at: 2025-04-08T01:04:21Z
author_association: MEMBER
diff_hunk: @@ -601,3 +601,16 @@ export function get_retry_backoff_seconds(
     }
     return Math.max(backoff_delay_secs, rate_limit_delay_secs);
 }
+
+export async function sha256_hash(text: string): Promise<string | undefined> {
+    // The Web Crypto API is only available in secure contexts (HTTPS).
+    if (!window.isSecureContext) {
+        return undefined;
+    }
path: web/src/util.ts
user: login: kuv2707
body: This guard clause exists because the puppeteer tests would fail without it, as the test server is not a secure context (http).
created_at: 2025-04-08T01:51:01Z
author_association: COLLABORATOR
diff_hunk: @@ -601,3 +601,16 @@ export function get_retry_backoff_seconds(
     }
     return Math.max(backoff_delay_secs, rate_limit_delay_secs);
 }
+
+export async function sha256_hash(text: string): Promise<string | undefined> {
+    // The Web Crypto API is only available in secure contexts (HTTPS).
+    if (!window.isSecureContext) {
+        return undefined;
+    }
path: web/src/util.ts
user: login: kuv2707
body: Returning undefined basically says that we do not protect against race conditions if you're not in a secure context.

It is anyway needed to make the puppeteer tests pass.
created_at: 2025-04-08T14:53:31Z
author_association: COLLABORATOR
diff_hunk: @@ -1263,8 +1263,11 @@ export function save_message_row_edit($row: JQuery): void {
         return;
     }
 
-    const request = {message_id: message.id, content: new_content};
-
+    const request = {
+        message_id: message.id,
+        content: new_content,
+        prev_content_sha256: await util.sha256_hash(old_content ?? ""),
path: web/src/message_edit.ts
user: login: kuv2707
body: Added an assert and removed the `??`.
created_at: 2025-04-08T14:58:33Z
author_association: COLLABORATOR
diff_hunk: @@ -333,11 +333,19 @@ add_example("update_message", "/messages/{message_id}:patch", 200, async (client
     const result = await client.messages.send(request);
     const message_id = result.id;
 
+    async function sha256_hash(text) {
+        const msgUint8 = new TextEncoder().encode(text);
+        const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8);
+        const hashArray = new Uint8Array(hashBuffer);
+        return hashArray.toString();
+    }
+
     // {code_example|start}
     // Update a message with the given "message_id"
     const params = {
         message_id,
         content: "New Content",
+        prev_content: sha256_hash("I come not, friends, to steal away your hearts."),
path: zerver/openapi/javascript_examples.js
user: login: kuv2707
body: Added:
```
// Optional: Setting this protects against race conditions when multiple
// users edit the same message.
```
created_at: 2025-04-08T15:00:04Z
author_association: COLLABORATOR
diff_hunk: @@ -9002,6 +9002,18 @@ paths:
                   example: true
                 content:
                   $ref: "#/components/schemas/OptionalContent"
+                prev_content_sha256:
+                  description: |
+                    The sha256 hash of the previous raw content of the
path: zerver/openapi/zulip.yaml
user: login: gnprice
body: The name of this hash function is "SHA-256":
```suggestion
                    The SHA-256 hash of the previous raw content of the
```

See e.g. https://en.wikipedia.org/wiki/SHA-2 .

(In general, one of the things Wikipedia typically is excellent at is getting the names of things exactly pedantically correct. When in doubt about the canonical name for something, or its spelling or capitalization etc., I recommend looking it up in Wikipedia. Especially good is if the answer is the title of a Wikipedia article. In this case it's one of the bolded names in the intro of the article, because all the hash functions in this family share a single article.)

created_at: 2025-04-16T05:32:18Z
author_association: MEMBER
diff_hunk: @@ -684,16 +684,29 @@ def msg_format() -> str:
         return _("You do not have permission to use topic wildcard mentions in this topic.")
 
 
-class PreviousSettingValueMismatchedError(JsonableError):
+class ExpectationMismatchError(JsonableError):
     code: ErrorCode = ErrorCode.EXPECTATION_MISMATCH
+    data_fields = ["field_name"]
 
     def __init__(self) -> None:
-        pass
+        self.field_name = "field"
path: zerver/lib/exceptions.py
user: login: timabbott
body: I don't love having this here, but I guess it's quite unlikely code can pass any tests that fails to override it.
created_at: 2025-04-16T20:13:04Z
author_association: MEMBER
diff_hunk: @@ -1397,7 +1405,16 @@ def check_update_message(
         if topic_name == message.topic_name():
             topic_name = None
 
-    validate_message_edit_payload(message, stream_id, topic_name, propagate_mode, content)
+    if prev_content_sha256 is None:
+        # Most production callers will have computed this as part of
+        # verifying whether there's an actual change to make, but it
+        # feels quite clumsy to have to pass it from unit tests, so we
+        # compute it here if not provided by the caller.
+        prev_content_sha256 = utils.sha256_hash(message.content)
path: zerver/actions/message_edit.py
user: login: timabbott
body: We should just not require the field.
created_at: 2025-04-16T20:14:31Z
author_association: MEMBER
diff_hunk: @@ -1298,13 +1299,14 @@ def test_private_message_invalid_recipient(client: Client) -> None:
 
 
 @openapi_test_function("/messages/{message_id}:patch")
-def update_message(client: Client, message_id: int) -> None:
+def update_message(client: Client, message_id: int, prev_content: str) -> None:
     # {code_example|start}
     # Edit a message. Make sure that `message_id` is set to the ID of the
     # message you wish to update.
     request = {
         "message_id": message_id,
         "content": "New content",
+        "prev_content_sha256": utils.sha256_hash(prev_content),
path: zerver/openapi/python_examples.py
user: login: timabbott
body: This isn't good for the documentation -- most clients won't bother with this, or have a `utils.sha256_hash` function.
created_at: 2025-04-16T20:15:27Z
author_association: MEMBER

PR comments:
user: login: kuv2707
created_at: 2025-03-31T14:25:18Z
body: Puppeteer tests not working locally have made it hard to fix the failing tests. 
[#development help > ✔ The Puppeteer frontend tests failed @ 💬](https://chat.zulip.org/#narrow/channel/49-development-help/topic/.E2.9C.94.20The.20Puppeteer.20frontend.20tests.20failed/near/2138728)
author_association: COLLABORATOR
user: login: timabbott
created_at: 2025-04-08T01:02:49Z
body: Pushed some minor tweaks, but I have to step away for the day, so please address the above.
``` diff
diff --git a/api_docs/changelog.md b/api_docs/changelog.md
index 019cd702bd..115a616a40 100644
--- a/api_docs/changelog.md
+++ b/api_docs/changelog.md
@@ -23,10 +23,9 @@ format used by the Zulip server that they are interacting with.
 **Feature level 378**
 
 * [`PATCH /messages/{message_id}`](/api/update-message): Added
- optional parameter `prev_content_sha256` which is the sha256 hash
- of the present message content, used to check the
- client side version of the message with the one in db before
- updating the message content.
+ optional parameter `prev_content_sha256` which is the client's
+ calculation sha256 hash of the present message content, used to
+ prevent races.
 
 **Feature level 377**
 
diff --git a/web/src/util.ts b/web/src/util.ts
index 25ce688f3c..e4baf8889e 100644
--- a/web/src/util.ts
+++ b/web/src/util.ts
@@ -603,10 +603,9 @@ export function get_retry_backoff_seconds(
 }
 
 export async function sha256_hash(text: string): Promise<string | undefined> {
-    // The Web Crypto API is only available in secure contexts (HTTPS).
-    if (!window.isSecureContext) {
-        return undefined;
-    }
+    // The Web Crypto API is only available in secure contexts (HTTPS or localhost).
+    assert(window.isSecureContext);
+
     const encoder = new TextEncoder();
     const data = encoder.encode(text);
     const hashBuffer = await crypto.subtle.digest("SHA-256", data);

```
author_association: MEMBER
user: login: kuv2707
created_at: 2025-04-08T15:13:19Z
body: @timabbott Addressed the feedback.
I ended up reverting the changes in `util.ts` due to reasons mentioned [above](https://github.com/zulip/zulip/pull/34152#discussion_r2032252727).
author_association: COLLABORATOR
user: login: timabbott
created_at: 2025-04-16T20:33:13Z
body: Applied these changes:

``` diff
diff --git a/zerver/actions/message_edit.py b/zerver/actions/message_edit.py
index 471870e033..1f6f4aae87 100644
--- a/zerver/actions/message_edit.py
+++ b/zerver/actions/message_edit.py
@@ -112,8 +112,7 @@ def validate_message_edit_payload(
     prev_content_sha256: str | None,
 ) -> None:
     """
-    Checks that the data sent is well-formed. Does not handle editability (but
-    checks for races in message edit requests), permissions etc.
+    Validates that a message edit request is well-formed. Does not handle permissions.
     """
     if topic_name is None and content is None and stream_id is None:
         raise JsonableError(_("Nothing to change"))
@@ -146,7 +145,12 @@ def validate_message_edit_payload(
     if content is not None and is_widget_message(message):
         raise JsonableError(_("Widgets cannot be edited."))
 
-    if content is not None and prev_content_sha256 != utils.sha256_hash(message.content):
+    # We don't restrict this check to requests to edit content, to
+    # give the parameter a pure meaning. But clients sending this for
+    # topic moves are likely doing so in error.
+    if prev_content_sha256 is not None and prev_content_sha256 != utils.sha256_hash(
+        message.content
+    ):
         raise PreviousMessageContentMismatchedError
 
 
@@ -1405,13 +1409,6 @@ def check_update_message(
         if topic_name == message.topic_name():
             topic_name = None
 
-    if prev_content_sha256 is None:
-        # Most production callers will have computed this as part of
-        # verifying whether there's an actual change to make, but it
-        # feels quite clumsy to have to pass it from unit tests, so we
-        # compute it here if not provided by the caller.
-        prev_content_sha256 = utils.sha256_hash(message.content)
-
     validate_message_edit_payload(
         message, stream_id, topic_name, propagate_mode, content, prev_content_sha256
     )
diff --git a/zerver/openapi/javascript_examples.js b/zerver/openapi/javascript_examples.js
index c92ee1dba1..ffde3eba90 100644
--- a/zerver/openapi/javascript_examples.js
+++ b/zerver/openapi/javascript_examples.js
@@ -333,21 +333,11 @@ add_example("update_message", "/messages/{message_id}:patch", 200, async (client
     const result = await client.messages.send(request);
     const message_id = result.id;
 
-    async function sha256_hash(text) {
-        const msgUint8 = new TextEncoder().encode(text);
-        const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8);
-        const hashArray = new Uint8Array(hashBuffer);
-        return hashArray.toString();
-    }
-
     // {code_example|start}
     // Update a message with the given "message_id"
     const params = {
         message_id,
         content: "New Content",
-        // Optional: Setting this protects against race conditions when multiple
-        // users edit the same message.
-        prev_content: sha256_hash("I come not, friends, to steal away your hearts."),
     };
 
     console.log(await client.messages.update(params));
diff --git a/zerver/openapi/python_examples.py b/zerver/openapi/python_examples.py
index 6c06645e62..d4fb93a17b 100644
--- a/zerver/openapi/python_examples.py
+++ b/zerver/openapi/python_examples.py
@@ -23,7 +23,6 @@ from typing import Any, TypeVar
 from typing_extensions import ParamSpec
 from zulip import Client
 
-from zerver.lib import utils
 from zerver.models.realms import get_realm
 from zerver.models.users import get_user
 from zerver.openapi.openapi import validate_against_openapi_schema
@@ -1300,13 +1299,16 @@ def test_private_message_invalid_recipient(client: Client) -> None:
 
 @openapi_test_function("/messages/{message_id}:patch")
 def update_message(client: Client, message_id: int, prev_content: str) -> None:
+    # We elect not to pass prev_content_sha256, because at present, it
+    # is likely to be experienced as clutter for almost all end users
+    # of this API.
+    #
     # {code_example|start}
     # Edit a message. Make sure that `message_id` is set to the ID of the
     # message you wish to update.
     request = {
         "message_id": message_id,
         "content": "New content",
-        "prev_content_sha256": utils.sha256_hash(prev_content),
     }
     result = client.update_message(request)
     # {code_example|end}
diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml
index cbdee232da..71e480a3e3 100644
--- a/zerver/openapi/zulip.yaml
+++ b/zerver/openapi/zulip.yaml
@@ -9004,14 +9004,16 @@ paths:
                   $ref: "#/components/schemas/OptionalContent"
                 prev_content_sha256:
                   description: |
-                    The sha256 hash of the previous raw content of the
-                    message that the client has at the time of the request, used
-                    to check for races when many users are editing the same messages.
-                    If it doesn't match the hash of the current message content
-                    in the database, the edit request is rejected.
+                    An optional SHA-256 hash of the previous raw content of the message
+                    that the client has at the time of the request.
 
-                    **Changes**: New in Zulip 11.0 (feature level 379).
+                    If provided, the server will return an error if it does not match the
+                    SHA-256 hash of the message's content stored in the database.
+
+                    Clients can use this feature to prevent races where multiple clients
+                    save conflicting edits to a message.
 
+                    **Changes**: New in Zulip 11.0 (feature level 379).
                   type: string
                   example: "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72" # "test content"
                 stream_id:

```
author_association: MEMBER
user: login: timabbott
created_at: 2025-04-16T20:41:27Z
body: I also added a bonus commit fixing something I noticed while reviewing this.

Looks good, marked to merge once CI passes. Thanks @kuv2707! It was definitely helpful to split this out as its own PR.

BTW for you next API change PR, use `tools/create-api-changelog`.
author_association: MEMBER