The ChatDelegate is a key-value storage system for chat applications in Freenet. It provides origin-based data partitioning similar to a web browser's localStorage API, ensuring data isolation between different applications. This document explains how the delegate handles different types of messages and the overall flow of operations.
The ChatDelegate provides four main operations:
- Store - Save data with a specific key
- Get - Retrieve data for a key
- Delete - Remove data for a key
- List - Get all available keys
Each operation involves a multi-step process due to the asynchronous nature of the delegate system.
A key security feature of the ChatDelegate is that all data is partitioned by "origin contract" - the contract key from which the user interface was downloaded. This works similarly to a web browser's localStorage, where data is partitioned by the hostname/port of the website:
- Each piece of data is stored with a composite key that includes the origin contract ID
- Applications can only access data that was stored by the same origin
- This prevents different applications from accessing each other's data
- The partitioning happens automatically and is transparent to the application
This design ensures that even if multiple chat applications use the same delegate, their data remains isolated and secure from each other.
The delegate uses a state machine pattern where:
- Initial application messages trigger operations
- State is stored in a context object between operations
- Responses from the storage system trigger follow-up actions
The entry point is the process method in lib.rs, which handles four types of messages:
match message {
InboundDelegateMsg::ApplicationMessage(app_msg) => {
// Handle client requests (Store/Get/Delete/List)
handle_application_message(app_msg, &origin)
}
InboundDelegateMsg::GetSecretResponse(response) => {
// Handle responses from the storage system
handle_get_secret_response(response)
}
InboundDelegateMsg::UserResponse(_) => {
// Not used in this delegate
Ok(vec![])
}
InboundDelegateMsg::GetSecretRequest(_) => {
// Not handled directly
Err(DelegateError::Other("unexpected message type: get secret request".into()))
}
}Before diving into specific flows, let's understand some key concepts:
- Origin: The contract ID that identifies the application
- ChatDelegateKey: A wrapper around a byte vector that represents a key
- KeyIndex: A list of all keys for a specific origin
- PendingOperation: State stored in the context to track ongoing operations
When a client wants to store data:
-
Client sends a StoreRequest:
ChatDelegateRequestMsg::StoreRequest { key, value }
-
Delegate processes the request (
handle_store_request):- Creates a unique storage key by combining the origin and client key
- Stores the operation in context for later processing
- Immediately sends back a success response to the client
- Sends a request to store the actual value
- Requests the current key index to update it
-
Delegate receives the key index (
handle_key_index_response):- Adds the new key to the index if it doesn't exist
- Updates the index in storage
- The operation is complete
When a client wants to retrieve data:
-
Client sends a GetRequest:
ChatDelegateRequestMsg::GetRequest { key }
-
Delegate processes the request (
handle_get_request):- Creates the unique storage key
- Stores the operation in context
- Sends a request to get the value from storage
-
Delegate receives the value (
handle_regular_get_response):- Retrieves the pending operation from context
- Sends the value back to the client
- The operation is complete
When a client wants to delete data:
-
Client sends a DeleteRequest:
ChatDelegateRequestMsg::DeleteRequest { key }
-
Delegate processes the request (
handle_delete_request):- Creates the unique storage key
- Stores the operation in context
- Immediately sends back a success response to the client
- Sends a request to delete the value (by setting it to None)
- Requests the current key index to update it
-
Delegate receives the key index (
handle_key_index_response):- Removes the key from the index
- Updates the index in storage
- The operation is complete
When a client wants to list all keys:
-
Client sends a ListRequest:
ChatDelegateRequestMsg::ListRequest
-
Delegate processes the request (
handle_list_request):- Stores the operation in context
- Requests the current key index
-
Delegate receives the key index (
handle_key_index_response):- Sends the list of keys back to the client
- The operation is complete
A critical aspect of the delegate is how it manages keys:
-
Origin-Based Key Namespacing: Each key is prefixed with the origin contract ID to enforce data partitioning:
pub(crate) fn create_origin_key(origin: &Origin, key: &ChatDelegateKey) -> SecretsId { SecretsId::new( format!("{}{}{}", origin.to_b58(), ORIGIN_KEY_SEPARATOR, String::from_utf8_lossy(key.as_bytes()).to_string()).into_bytes() ) }
This ensures that:
- Data from different origins is completely isolated
- Applications can only access their own data
- Key collisions between different applications are impossible
-
Origin-Specific Key Index: Each origin has its own separate key index:
pub(crate) fn create_index_key(origin: &Origin) -> SecretsId { SecretsId::new(format!( "{}{}{}", origin.to_b58(), ORIGIN_KEY_SEPARATOR, KEY_INDEX_SUFFIX ).into_bytes()) }
This means:
- Each application has its own isolated list of keys
- The ListRequest operation only returns keys for the calling application's origin
- Applications cannot enumerate keys from other origins
The delegate uses a context object to maintain state between operations:
pub(super) struct ChatDelegateContext {
pub(super) pending_ops: HashMap<SecretIdKey, PendingOperation>,
}This context is serialized and passed along with requests, then deserialized when responses are received.
The overall flow for any operation follows this pattern:
- Client Request: Application sends a request to the delegate
- Initial Processing: Delegate creates necessary storage keys and stores state in context
- Storage Operations: Delegate interacts with the storage system
- Response Handling: Delegate processes storage responses and updates state
- Client Response: Delegate sends final response back to the application
This asynchronous, multi-step approach allows the delegate to maintain consistency while providing a simple interface to client applications.
Let's trace a complete store operation with origin partitioning:
- Client sends
StoreRequest { key: "user123", value: [profile data] } - Delegate:
- Identifies the origin contract ID (e.g.,
abc123) from which the request came - Creates storage key:
abc123:user123(prefixing the client key with origin) - Creates index key:
abc123::key_index(origin-specific index) - Stores pending operation in context
- Sends success response to client
- Sends request to store value at
abc123:user123 - Sends request to get current index at
abc123::key_index
- Identifies the origin contract ID (e.g.,
- Delegate receives index (or empty if first key)
- Delegate:
- Adds "user123" to index if not present
- Updates index in storage
- Operation complete
If a different application with origin xyz789 tries to access this data:
- It would use key
xyz789:user123which is different fromabc123:user123 - It would not find the data stored by the first application
- It would have its own separate key index at
xyz789::key_index
This architecture ensures both data consistency and security through origin-based isolation, while providing a responsive experience for client applications.