···11+# Rust ATProto Counter Demo - Design Overview
22+33+This document outlines the design and goals of the Rust ATProto Counter Demo application.
44+55+## 1. Project Goal
66+77+The primary goal of this project is to build a simple web application using Rust (compiled to WebAssembly with Yew) that demonstrates integration with the AT Protocol. The core functionality will be a distributed counter.
88+99+Users will be able to:
1010+1. Authenticate with their AT Protocol (Bluesky) account via OAuth.
1111+2. Increment a counter.
1212+3. When a user increments the counter, the application will publish a unique, singleton record to their personal data repository (PDS). This record signifies their participation in the count.
1313+4. (Future) The frontend might eventually listen to a firehose stream for these specific records from all users to display a global count.
1414+1515+This project serves as a learning exercise and a demonstration of using the `atrium-rs` SDK for AT Protocol interactions in a Rust Wasm context.
1616+1717+## 2. Project Structure
1818+1919+The `rust_demo_app/` directory contains the core application code, structured as a Cargo workspace:
2020+2121+* **`api_demo/`**: A simple Actix-based backend API (currently providing basic counter GET/POST/PUT endpoints). In the context of the ATProto distributed counter, this backend's role might evolve or be less central if the counter state is primarily managed via ATProto records.
2222+* **`app_demo/`**: The Yew frontend application (Rust compiled to Wasm). This is where the ATProto authentication and record creation logic will reside.
2323+* **`types_demo/`**: Shared data types between the frontend and backend, including lexicon definitions for any custom ATProto records (e.g., `dev.alternatebuild.rustDemo.count`).
2424+2525+## 3. AT Protocol Integration Strategy
2626+2727+### 3.1. Authentication
2828+* The `app_demo` frontend will use the `atrium-oauth` crate to handle OAuth 2.0 authentication against a user's PDS.
2929+* The flow will be:
3030+ 1. User clicks "Login".
3131+ 2. App redirects to the PDS authorization endpoint.
3232+ 3. User approves.
3333+ 4. PDS redirects back to an `/oauth/callback` route in our Yew app.
3434+ 5. App exchanges the authorization code for an access token and session data.
3535+ 6. Session data (including access and refresh tokens) will be stored securely in the browser's IndexedDB using helper functions and the `atrium_common::store::Store` trait.
3636+3737+### 3.2. Distributed Counter Record
3838+* **Lexicon:** A custom lexicon, `dev.alternatebuild.rustDemo.count`, will be defined.
3939+ * `nsid`: `dev.alternatebuild.rustDemo.count`
4040+ * `key`: `literal:self` (ensuring only one such record per repository, making it a singleton by design for this use case).
4141+ * The record schema itself might be simple, e.g., containing a timestamp or a minimal "counted: true" field. The existence and timestamp of the record are the primary signals.
4242+* **Action:** When an authenticated user clicks "Increment Counter":
4343+ 1. The `app_demo` frontend will use the `atrium-api` (via an authenticated `XrpcClient` or `Agent`) to make a `com.atproto.repo.createRecord` call.
4444+ 2. This will create (or update, if `swapCommit` logic is used, though `createRecord` for a new `rkey` each time might be simpler if we only care about the *act* of counting) the `dev.alternatebuild.rustDemo.count` record in their repository. For a true singleton identified by `rkey: "self"`, we'd use `putRecord` to create or update. Given `key: "literal:self"` in the lexicon usually implies the `rkey` will be fixed (often also "self" or a predefined string), `putRecord` is more appropriate for creating/updating a singleton record at a known path. If the lexicon truly means any `rkey` is fine as long as the record name is `self` this implies a collection where only one item has the name self. *Correction*: the `key: "literal:self"` in the lexicon refers to the record's *name* within the collection, not the `rkey` itself. A common pattern for singletons is to use a fixed `rkey` like `"self"`.
4545+ Let's assume we'll use `com.atproto.repo.putRecord` with `rkey: "self"` for this singleton.
4646+4747+### 3.3. (Future) Global Count Aggregation
4848+* To display a global count, the frontend could connect to a Bluesky Appview's firehose subscription endpoint (e.g., `com.atproto.sync.subscribeRepos`).
4949+* It would filter for `createRecord` or `putRecord` operations related to the `dev.alternatebuild.rustDemo.count` NSID.
5050+* The frontend would maintain a client-side aggregation of these records to display a live global count. This avoids a centralized counter backend.
5151+5252+## 4. Reference Repositories (Informational Only)
5353+5454+Within the broader workspace (but **not** part of the `rust_demo_app` build itself), two repositories have been cloned for reference purposes. These are used to understand best practices, see `atrium-rs` usage examples, and borrow code patterns. They are **not** dependencies of `rust_demo_app` and are only present to be read from (e.g., using `rg` - ripgrep) during development.
5555+5656+* **`atrium/`**: A clone of the main [`atrium-rs/atrium`](https://github.com/atrium-rs/atrium) repository. This provides access to the source code of the SDK crates themselves (`atrium-api`, `atrium-oauth`, `atrium-common`, etc.) and internal examples or tools like `lexgen`.
5757+* **`at_2048/`**: A clone of the [`fatfingers23/at_2048`](https://github.com/fatfingers23/at_2048) project. This is a more complete ATProto-enabled web application (a 2048 game) built with Yew and `atrium-rs`. It serves as an excellent practical example for:
5858+ * OAuth flow implementation in Yew.
5959+ * `XrpcClient` and `Agent` usage.
6060+ * Session storage in IndexedDB.
6161+ * Lexicon usage and record manipulation.
6262+ * Yew application structure with ATProto.
6363+6464+These repositories are invaluable for accelerating development and ensuring correct usage of the `atrium-rs` ecosystem.
···55use types_demo::CounterState;
66use wasm_bindgen_futures::spawn_local;
77use yew::prelude::*;
88+use yew_router::prelude::*;
99+use yewdux::prelude::*;
1010+1111+// Import the Store trait
1212+use atrium_common::store::Store;
813914mod atrium_stores;
1015mod idb;
1116mod oauth_client;
1717+1818+// Add pages module
1919+pub mod pages;
2020+2121+// Import page components
2222+use crate::pages::callback::CallbackPage;
2323+use crate::pages::home::HomePage;
2424+use crate::pages::login::LoginPage;
2525+2626+// Import oauth client
2727+use crate::oauth_client::get_oauth_client;
2828+use atrium_api::agent::Agent;
2929+use atrium_api::types::string::Did;
12301331const API_ROOT: &str = "";
1432···6684 }
6785}
68868787+// Yewdux Store for Authentication State
8888+#[derive(Default, Clone, PartialEq, Store)]
8989+pub struct AuthStore {
9090+ pub did: Option<Did>, // Store the user's DID
9191+ // We could store the agent here too, but it's not directly serializable by default for Store
9292+ // So we might need a transient way or reconstruct it. For now, just DID.
9393+ // #[store(skip_serializing)] // If we were to store Agent here
9494+ // pub agent: Option<Arc<Agent<crate::oauth_client::OAuthClientType>>>, // This might be complex due to agent's client type
9595+}
9696+9797+// Define the routes
9898+#[derive(Clone, Routable, PartialEq)]
9999+pub enum AppRoute {
100100+ #[at("/")]
101101+ Home,
102102+ #[at("/login")]
103103+ Login,
104104+ #[at("/oauth/callback")]
105105+ Callback,
106106+ #[not_found]
107107+ #[at("/404")]
108108+ NotFound,
109109+}
110110+111111+fn switch(routes: AppRoute) -> Html {
112112+ match routes {
113113+ AppRoute::Home => html! { <HomePage /> },
114114+ AppRoute::Login => html! { <LoginPage /> },
115115+ AppRoute::Callback => html! { <CallbackPage /> },
116116+ AppRoute::NotFound => html! { <h1>{ "404 Not Found" }</h1> },
117117+ }
118118+}
119119+69120#[function_component(App)]
7070-fn app() -> Html {
7171- let state = use_reducer(AppState::default);
121121+pub fn app() -> Html {
122122+ let (auth_store, auth_dispatch) = use_store::<AuthStore>();
123123+ let navigator = use_navigator();
721247373- // Initial load effect
125125+ // Effect for session restoration
74126 {
7575- let state = state.clone();
127127+ let auth_dispatch = auth_dispatch.clone(); // auth_dispatch for the effect
128128+ let auth_store_for_effect = auth_store.clone(); // auth_store for the effect
76129 use_effect_with((), move |_| {
7777- state.dispatch(CounterAction::LoadCounter);
7878- fetch_counter(state);
130130+ log::info!("App loaded. AuthStore DID: {:?}", auth_store_for_effect.did);
131131+ // ... session restoration logic will go here ...
79132 || ()
80133 });
81134 }
821358383- // Define event handlers
8484- let on_reset_click = {
8585- let state = state.clone();
136136+ // Clone auth_store for the logout callback
137137+ let auth_store_for_logout = auth_store.clone();
138138+ let on_logout = {
139139+ let auth_dispatch = auth_dispatch.clone(); // auth_dispatch for logout
140140+ let navigator = navigator.clone();
141141+ // auth_store_for_logout is already cloned above and will be captured by the closure
86142 Callback::from(move |_| {
8787- state.dispatch(CounterAction::ResetRequested);
8888- reset_counter(state.clone());
8989- })
9090- };
143143+ let auth_dispatch = auth_dispatch.clone(); // further clone for spawn_local
144144+ let navigator = navigator.clone(); // further clone for spawn_local
145145+ let auth_store_captured = auth_store_for_logout.clone(); // clone for spawn_local
911469292- let on_increment_click = {
9393- let state = state.clone();
9494- Callback::from(move |_| {
9595- // Special handling for value 10
9696- if let Some(counter) = &state.counter {
9797- if counter.value == 10 {
9898- state.dispatch(CounterAction::ClearError);
9999- }
147147+ if let Some(did_to_logout) = auth_store_captured.did.clone() {
148148+ yew::platform::spawn_local(async move {
149149+ let store = crate::atrium_stores::IndexDBSessionStore::new();
150150+ match store.del(&did_to_logout).await {
151151+ Ok(_) => {
152152+ log::info!("Session deleted from store for DID: {:?}", did_to_logout)
153153+ }
154154+ Err(e) => log::error!("Failed to delete session from store: {:?}", e),
155155+ }
156156+ auth_dispatch.set(AuthStore { did: None });
157157+ if let Some(nav) = &navigator {
158158+ nav.push(&AppRoute::Home);
159159+ }
160160+ });
100161 }
101101- increment_counter(state.clone());
102162 })
103163 };
104164105105- // Render counter display
106106- let counter_display = match &state.counter {
107107- Some(counter) => {
108108- html! { <p class="counter-value">{ format!("Current value: {}", counter.value) }</p> }
109109- }
110110- None => html! { <p class="counter-value loading">{ "Loading..." }</p> },
111111- };
112112-113113- // Render error message if any
114114- let error_display = match &state.error {
115115- Some(err) => html! { <div class="error-message">{ format!("Error: {}", err) }</div> },
116116- None => html! {<> </>},
117117- };
118118-119165 html! {
120120- <div class="counter-app">
121121- <h1>{ "Rust Counter Demo" }</h1>
122122- { counter_display }
123123- { error_display }
124124- <div class="button-group">
125125- <button class="reset" onclick={on_reset_click}>{ "Reset" }</button>
126126- <button onclick={on_increment_click}>{ "Increment" }</button>
127127- </div>
128128- </div>
166166+ <BrowserRouter>
167167+ <nav class="navbar">
168168+ <div class="navbar-start">
169169+ <Link<AppRoute> to={AppRoute::Home} classes="btn btn-ghost normal-case text-xl">
170170+ { "Rust ATProto Counter" }
171171+ </Link<AppRoute>>
172172+ </div>
173173+ <div class="navbar-end">
174174+ if auth_store.did.is_some() {
175175+ <button onclick={on_logout} class="btn btn-ghost">{ "Logout" }</button>
176176+ } else {
177177+ <Link<AppRoute> to={AppRoute::Login} classes="btn btn-ghost">{ "Login" }</Link<AppRoute>>
178178+ }
179179+ </div>
180180+ </nav>
181181+ <main class="container mx-auto p-4">
182182+ <Switch<AppRoute> render={switch} />
183183+ </main>
184184+ </BrowserRouter>
129185 }
130186}
131187
+1
rust_demo_app/app_demo/src/oauth_client.rs
···131131 redirect_uris: Some(vec![format!("{}/oauth/callback", origin)]),
132132 scopes: Some(vec![
133133 Scope::Known(KnownScope::Atproto), // Full access to user's account
134134+ Scope::Known(KnownScope::TransitionGeneric), // Added this scope
134135 // Add other scopes if needed, e.g., for specific lexicons if ATProto supports granular scopes later
135136 ]),
136137 // Other fields like client_name, logo_uri can be added
+129
rust_demo_app/app_demo/src/pages/callback.rs
···11+use wasm_bindgen_futures::spawn_local;
22+use yew::prelude::*;
33+use yew_router::prelude::*;
44+use yewdux::prelude::*;
55+66+use crate::oauth_client::get_oauth_client;
77+use crate::{AppRoute, AuthStore}; // Assuming AppRoute and AuthStore are in lib.rs or main.rs and pub
88+99+// Import Store trait for state_store.get()
1010+use atrium_common::store::Store;
1111+// Import for parsing query string into CallbackParams
1212+use atrium_oauth::CallbackParams; // Assuming this is the correct path for 0.1.1
1313+ // We might need IndexDBStateStore if we manually delete the state after successful callback
1414+use crate::atrium_stores::IndexDBStateStore;
1515+// Import Agent to make authenticated calls
1616+use atrium_api::agent::Agent;
1717+1818+#[function_component(CallbackPage)]
1919+pub fn callback_page() -> Html {
2020+ let (_, auth_dispatch) = use_store::<AuthStore>();
2121+ let navigator = use_navigator().unwrap(); // Should always be available in a routed context
2222+ let location = use_location().unwrap(); // For accessing query parameters
2323+2424+ let error_message = use_state(|| None::<String>);
2525+2626+ {
2727+ let auth_dispatch = auth_dispatch.clone();
2828+ let navigator = navigator.clone();
2929+ let error_message = error_message.clone();
3030+3131+ use_effect_with((location.query_str().to_string(),), move |(query_str,)| {
3232+ let query_str = query_str.clone(); // Ensure query_str is owned for the async block
3333+ let auth_dispatch = auth_dispatch.clone();
3434+ let navigator = navigator.clone();
3535+ let error_message = error_message.clone();
3636+3737+ spawn_local(async move {
3838+ let params: CallbackParams = match serde_html_form::from_str(&query_str) {
3939+ Ok(p) => p,
4040+ Err(e) => {
4141+ log::error!("Failed to parse callback query parameters: {:?}", e);
4242+ error_message
4343+ .set(Some(format!("Error parsing callback parameters: {:?}", e)));
4444+ // Optionally redirect to login or show error
4545+ navigator.push(&AppRoute::Login);
4646+ return;
4747+ }
4848+ };
4949+5050+ // The `state` value within `params` will be used by `client.callback()`
5151+ // to retrieve necessary data (like PKCE verifier and original PDS host) from the state_store.
5252+ let state_from_query = params.state.clone(); // For potential manual deletion later
5353+5454+ let oauth_client = get_oauth_client().await;
5555+5656+ match oauth_client.callback(params).await {
5757+ Ok((session_data, _optional_dpop_nonce)) => {
5858+ log::info!("OAuth callback successful, creating Agent...");
5959+6060+ // Create Agent from the session
6161+ // Note: The concrete type of Agent might depend on how OAuthSession implements SessionManager
6262+ // Or Agent::new might be flexible enough.
6363+ let agent = Agent::new(session_data);
6464+6565+ // Get session info using the agent
6666+ match agent.api.com.atproto.server.get_session().await {
6767+ Ok(session_info) => {
6868+ log::info!(
6969+ "Successfully retrieved session info. DID: {:?}, Handle: {:?}",
7070+ session_info.did,
7171+ session_info.handle
7272+ );
7373+ // Extract the DID from the getSession response
7474+ let user_did = session_info.did.clone(); // Clone the Did
7575+7676+ // Store the cloned DID
7777+ auth_dispatch.set(AuthStore {
7878+ did: Some(user_did),
7979+ }); // No need to clone again here
8080+8181+ // Attempt to delete the used state from the state store (keep this logic)
8282+ let state_store = IndexDBStateStore::new();
8383+ if let Some(state_key_to_delete) = state_from_query {
8484+ if let Err(e) = state_store.del(&state_key_to_delete).await {
8585+ log::warn!(
8686+ "Failed to delete state from store after use: {:?}",
8787+ e
8888+ );
8989+ }
9090+ } else {
9191+ log::warn!(
9292+ "No state found in CallbackParams to delete from store."
9393+ );
9494+ }
9595+ navigator.push(&AppRoute::Home);
9696+ }
9797+ Err(e) => {
9898+ log::error!(
9999+ "Failed to get session info using agent after successful callback: {:?}",
100100+ e
101101+ );
102102+ error_message
103103+ .set(Some(format!("Failed to verify session: {:?}", e)));
104104+ navigator.push(&AppRoute::Login);
105105+ }
106106+ }
107107+ }
108108+ Err(e) => {
109109+ log::error!("OAuth callback processing failed: {:?}", e);
110110+ error_message.set(Some(format!("Login failed during callback: {:?}", e)));
111111+ navigator.push(&AppRoute::Login);
112112+ }
113113+ }
114114+ });
115115+ || ()
116116+ });
117117+ }
118118+119119+ html! {
120120+ <div>
121121+ if let Some(err) = &*error_message {
122122+ <p class="text-red-500">{ format!("Error during login: {}", err) }</p>
123123+ <p><Link<AppRoute> to={AppRoute::Login}>{ "Try logging in again" }</Link<AppRoute>></p>
124124+ } else {
125125+ <p>{ "Processing login, please wait..." }</p>
126126+ }
127127+ </div>
128128+ }
129129+}
+11
rust_demo_app/app_demo/src/pages/home.rs
···11+use yew::prelude::*;
22+33+#[function_component(HomePage)]
44+pub fn home_page() -> Html {
55+ html! {
66+ <div>
77+ <h1>{ "Welcome to the ATProto Counter!" }</h1>
88+ // Counter display and increment button will go here
99+ </div>
1010+ }
1111+}
+105
rust_demo_app/app_demo/src/pages/login.rs
···11+use gloo::utils::window;
22+use wasm_bindgen_futures::spawn_local;
33+use web_sys::HtmlInputElement;
44+use yew::prelude::*;
55+66+use crate::oauth_client::get_oauth_client;
77+// use crate::AppRoute; // Removed unused import
88+use atrium_oauth::{AuthorizeOptions, KnownScope, Scope};
99+1010+#[function_component(LoginPage)]
1111+pub fn login_page() -> Html {
1212+ let pds_host = use_state(|| "https://bsky.social".to_string());
1313+ let error_message = use_state(|| None::<String>);
1414+1515+ let on_pds_host_input = {
1616+ let pds_host = pds_host.clone();
1717+ Callback::from(move |e: InputEvent| {
1818+ let input: HtmlInputElement = e.target_unchecked_into();
1919+ pds_host.set(input.value());
2020+ })
2121+ };
2222+2323+ let on_submit = {
2424+ let pds_host = pds_host.clone();
2525+ let error_message = error_message.clone();
2626+2727+ Callback::from(move |e: SubmitEvent| {
2828+ e.prevent_default();
2929+ let pds_host_val = pds_host.trim();
3030+ if pds_host_val.is_empty() {
3131+ error_message.set(Some("PDS Host cannot be empty".to_string()));
3232+ return;
3333+ }
3434+ if !pds_host_val.starts_with("https://") && !pds_host_val.starts_with("http://") {
3535+ error_message.set(Some(
3636+ "PDS Host must start with http:// or https://".to_string(),
3737+ ));
3838+ return;
3939+ }
4040+ error_message.set(None); // Clear previous error
4141+4242+ let pds_host_val = pds_host_val.to_string();
4343+ let error_message_clone = error_message.clone();
4444+4545+ spawn_local(async move {
4646+ match get_oauth_client()
4747+ .await
4848+ .authorize(
4949+ pds_host_val.clone(), // PDS host for authorization
5050+ AuthorizeOptions {
5151+ scopes: vec![
5252+ Scope::Known(KnownScope::Atproto),
5353+ Scope::Known(KnownScope::TransitionGeneric),
5454+ ],
5555+ // The state parameter is generated and stored by the authorize method itself.
5656+ // It will be associated with the PKCE code verifier and the PDS host.
5757+ ..Default::default()
5858+ },
5959+ )
6060+ .await
6161+ {
6262+ Ok(auth_url_with_state) => {
6363+ // auth_url_with_state includes the `state` query parameter generated by the SDK.
6464+ // The SDK's state_store will have stored the pkce_code_verifier and pds_host against this state.
6565+ if let Err(e) = window().location().set_href(auth_url_with_state.as_str()) {
6666+ log::error!("Failed to redirect: {:?}", e);
6767+ error_message_clone.set(Some(format!("Failed to redirect: {:?}", e)));
6868+ }
6969+ // No need to navigate with yew_router here, browser redirect handles it.
7070+ }
7171+ Err(e) => {
7272+ log::error!("Failed to get authorization URL: {:?}", e);
7373+ error_message_clone.set(Some(format!("Login failed: {:?}", e)));
7474+ }
7575+ }
7676+ });
7777+ })
7878+ };
7979+8080+ html! {
8181+ <div class="w-full max-w-md mx-auto mt-10">
8282+ <form onsubmit={on_submit} class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
8383+ <h2 class="text-2xl font-bold mb-6 text-center">{ "Login to ATProto" }</h2>
8484+ <div class="mb-4">
8585+ <label class="block text-gray-700 text-sm font-bold mb-2" for="pds_host">
8686+ { "PDS Host" }
8787+ </label>
8888+ <input type="text" id="pds_host" value={(*pds_host).clone()}
8989+ oninput={on_pds_host_input}
9090+ class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
9191+ placeholder="e.g., https://bsky.social" />
9292+ </div>
9393+ if let Some(err) = &*error_message {
9494+ <p class="text-red-500 text-xs italic mb-4">{ err }</p>
9595+ }
9696+ <div class="flex items-center justify-between">
9797+ <button type="submit"
9898+ class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">
9999+ { "Login" }
100100+ </button>
101101+ </div>
102102+ </form>
103103+ </div>
104104+ }
105105+}
+3
rust_demo_app/app_demo/src/pages/mod.rs
···11+pub mod callback;
22+pub mod home;
33+pub mod login;