···1+# Rust ATProto Counter Demo - Design Overview
2+3+This document outlines the design and goals of the Rust ATProto Counter Demo application.
4+5+## 1. Project Goal
6+7+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.
8+9+Users will be able to:
10+1. Authenticate with their AT Protocol (Bluesky) account via OAuth.
11+2. Increment a counter.
12+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.
13+4. (Future) The frontend might eventually listen to a firehose stream for these specific records from all users to display a global count.
14+15+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.
16+17+## 2. Project Structure
18+19+The `rust_demo_app/` directory contains the core application code, structured as a Cargo workspace:
20+21+* **`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.
22+* **`app_demo/`**: The Yew frontend application (Rust compiled to Wasm). This is where the ATProto authentication and record creation logic will reside.
23+* **`types_demo/`**: Shared data types between the frontend and backend, including lexicon definitions for any custom ATProto records (e.g., `dev.alternatebuild.rustDemo.count`).
24+25+## 3. AT Protocol Integration Strategy
26+27+### 3.1. Authentication
28+* The `app_demo` frontend will use the `atrium-oauth` crate to handle OAuth 2.0 authentication against a user's PDS.
29+* The flow will be:
30+ 1. User clicks "Login".
31+ 2. App redirects to the PDS authorization endpoint.
32+ 3. User approves.
33+ 4. PDS redirects back to an `/oauth/callback` route in our Yew app.
34+ 5. App exchanges the authorization code for an access token and session data.
35+ 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.
36+37+### 3.2. Distributed Counter Record
38+* **Lexicon:** A custom lexicon, `dev.alternatebuild.rustDemo.count`, will be defined.
39+ * `nsid`: `dev.alternatebuild.rustDemo.count`
40+ * `key`: `literal:self` (ensuring only one such record per repository, making it a singleton by design for this use case).
41+ * 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.
42+* **Action:** When an authenticated user clicks "Increment Counter":
43+ 1. The `app_demo` frontend will use the `atrium-api` (via an authenticated `XrpcClient` or `Agent`) to make a `com.atproto.repo.createRecord` call.
44+ 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"`.
45+ Let's assume we'll use `com.atproto.repo.putRecord` with `rkey: "self"` for this singleton.
46+47+### 3.3. (Future) Global Count Aggregation
48+* To display a global count, the frontend could connect to a Bluesky Appview's firehose subscription endpoint (e.g., `com.atproto.sync.subscribeRepos`).
49+* It would filter for `createRecord` or `putRecord` operations related to the `dev.alternatebuild.rustDemo.count` NSID.
50+* The frontend would maintain a client-side aggregation of these records to display a live global count. This avoids a centralized counter backend.
51+52+## 4. Reference Repositories (Informational Only)
53+54+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.
55+56+* **`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`.
57+* **`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:
58+ * OAuth flow implementation in Yew.
59+ * `XrpcClient` and `Agent` usage.
60+ * Session storage in IndexedDB.
61+ * Lexicon usage and record manipulation.
62+ * Yew application structure with ATProto.
63+64+These repositories are invaluable for accelerating development and ensuring correct usage of the `atrium-rs` ecosystem.
···5use types_demo::CounterState;
6use wasm_bindgen_futures::spawn_local;
7use yew::prelude::*;
8+use yew_router::prelude::*;
9+use yewdux::prelude::*;
10+11+// Import the Store trait
12+use atrium_common::store::Store;
1314mod atrium_stores;
15mod idb;
16mod oauth_client;
17+18+// Add pages module
19+pub mod pages;
20+21+// Import page components
22+use crate::pages::callback::CallbackPage;
23+use crate::pages::home::HomePage;
24+use crate::pages::login::LoginPage;
25+26+// Import oauth client
27+use crate::oauth_client::get_oauth_client;
28+use atrium_api::agent::Agent;
29+use atrium_api::types::string::Did;
3031const API_ROOT: &str = "";
32···84 }
85}
8687+// Yewdux Store for Authentication State
88+#[derive(Default, Clone, PartialEq, Store)]
89+pub struct AuthStore {
90+ pub did: Option<Did>, // Store the user's DID
91+ // We could store the agent here too, but it's not directly serializable by default for Store
92+ // So we might need a transient way or reconstruct it. For now, just DID.
93+ // #[store(skip_serializing)] // If we were to store Agent here
94+ // pub agent: Option<Arc<Agent<crate::oauth_client::OAuthClientType>>>, // This might be complex due to agent's client type
95+}
96+97+// Define the routes
98+#[derive(Clone, Routable, PartialEq)]
99+pub enum AppRoute {
100+ #[at("/")]
101+ Home,
102+ #[at("/login")]
103+ Login,
104+ #[at("/oauth/callback")]
105+ Callback,
106+ #[not_found]
107+ #[at("/404")]
108+ NotFound,
109+}
110+111+fn switch(routes: AppRoute) -> Html {
112+ match routes {
113+ AppRoute::Home => html! { <HomePage /> },
114+ AppRoute::Login => html! { <LoginPage /> },
115+ AppRoute::Callback => html! { <CallbackPage /> },
116+ AppRoute::NotFound => html! { <h1>{ "404 Not Found" }</h1> },
117+ }
118+}
119+120#[function_component(App)]
121+pub fn app() -> Html {
122+ let (auth_store, auth_dispatch) = use_store::<AuthStore>();
123+ let navigator = use_navigator();
124125+ // Effect for session restoration
126 {
127+ let auth_dispatch = auth_dispatch.clone(); // auth_dispatch for the effect
128+ let auth_store_for_effect = auth_store.clone(); // auth_store for the effect
129 use_effect_with((), move |_| {
130+ log::info!("App loaded. AuthStore DID: {:?}", auth_store_for_effect.did);
131+ // ... session restoration logic will go here ...
132 || ()
133 });
134 }
135136+ // Clone auth_store for the logout callback
137+ let auth_store_for_logout = auth_store.clone();
138+ let on_logout = {
139+ let auth_dispatch = auth_dispatch.clone(); // auth_dispatch for logout
140+ let navigator = navigator.clone();
141+ // auth_store_for_logout is already cloned above and will be captured by the closure
142 Callback::from(move |_| {
143+ let auth_dispatch = auth_dispatch.clone(); // further clone for spawn_local
144+ let navigator = navigator.clone(); // further clone for spawn_local
145+ let auth_store_captured = auth_store_for_logout.clone(); // clone for spawn_local
0146147+ if let Some(did_to_logout) = auth_store_captured.did.clone() {
148+ yew::platform::spawn_local(async move {
149+ let store = crate::atrium_stores::IndexDBSessionStore::new();
150+ match store.del(&did_to_logout).await {
151+ Ok(_) => {
152+ log::info!("Session deleted from store for DID: {:?}", did_to_logout)
153+ }
154+ Err(e) => log::error!("Failed to delete session from store: {:?}", e),
155+ }
156+ auth_dispatch.set(AuthStore { did: None });
157+ if let Some(nav) = &navigator {
158+ nav.push(&AppRoute::Home);
159+ }
160+ });
161 }
0162 })
163 };
16400000000000000165 html! {
166+ <BrowserRouter>
167+ <nav class="navbar">
168+ <div class="navbar-start">
169+ <Link<AppRoute> to={AppRoute::Home} classes="btn btn-ghost normal-case text-xl">
170+ { "Rust ATProto Counter" }
171+ </Link<AppRoute>>
172+ </div>
173+ <div class="navbar-end">
174+ if auth_store.did.is_some() {
175+ <button onclick={on_logout} class="btn btn-ghost">{ "Logout" }</button>
176+ } else {
177+ <Link<AppRoute> to={AppRoute::Login} classes="btn btn-ghost">{ "Login" }</Link<AppRoute>>
178+ }
179+ </div>
180+ </nav>
181+ <main class="container mx-auto p-4">
182+ <Switch<AppRoute> render={switch} />
183+ </main>
184+ </BrowserRouter>
185 }
186}
187
+1
rust_demo_app/app_demo/src/oauth_client.rs
···131 redirect_uris: Some(vec![format!("{}/oauth/callback", origin)]),
132 scopes: Some(vec![
133 Scope::Known(KnownScope::Atproto), // Full access to user's account
0134 // Add other scopes if needed, e.g., for specific lexicons if ATProto supports granular scopes later
135 ]),
136 // Other fields like client_name, logo_uri can be added
···131 redirect_uris: Some(vec![format!("{}/oauth/callback", origin)]),
132 scopes: Some(vec![
133 Scope::Known(KnownScope::Atproto), // Full access to user's account
134+ Scope::Known(KnownScope::TransitionGeneric), // Added this scope
135 // Add other scopes if needed, e.g., for specific lexicons if ATProto supports granular scopes later
136 ]),
137 // Other fields like client_name, logo_uri can be added
···1+use wasm_bindgen_futures::spawn_local;
2+use yew::prelude::*;
3+use yew_router::prelude::*;
4+use yewdux::prelude::*;
5+6+use crate::oauth_client::get_oauth_client;
7+use crate::{AppRoute, AuthStore}; // Assuming AppRoute and AuthStore are in lib.rs or main.rs and pub
8+9+// Import Store trait for state_store.get()
10+use atrium_common::store::Store;
11+// Import for parsing query string into CallbackParams
12+use atrium_oauth::CallbackParams; // Assuming this is the correct path for 0.1.1
13+ // We might need IndexDBStateStore if we manually delete the state after successful callback
14+use crate::atrium_stores::IndexDBStateStore;
15+// Import Agent to make authenticated calls
16+use atrium_api::agent::Agent;
17+18+#[function_component(CallbackPage)]
19+pub fn callback_page() -> Html {
20+ let (_, auth_dispatch) = use_store::<AuthStore>();
21+ let navigator = use_navigator().unwrap(); // Should always be available in a routed context
22+ let location = use_location().unwrap(); // For accessing query parameters
23+24+ let error_message = use_state(|| None::<String>);
25+26+ {
27+ let auth_dispatch = auth_dispatch.clone();
28+ let navigator = navigator.clone();
29+ let error_message = error_message.clone();
30+31+ use_effect_with((location.query_str().to_string(),), move |(query_str,)| {
32+ let query_str = query_str.clone(); // Ensure query_str is owned for the async block
33+ let auth_dispatch = auth_dispatch.clone();
34+ let navigator = navigator.clone();
35+ let error_message = error_message.clone();
36+37+ spawn_local(async move {
38+ let params: CallbackParams = match serde_html_form::from_str(&query_str) {
39+ Ok(p) => p,
40+ Err(e) => {
41+ log::error!("Failed to parse callback query parameters: {:?}", e);
42+ error_message
43+ .set(Some(format!("Error parsing callback parameters: {:?}", e)));
44+ // Optionally redirect to login or show error
45+ navigator.push(&AppRoute::Login);
46+ return;
47+ }
48+ };
49+50+ // The `state` value within `params` will be used by `client.callback()`
51+ // to retrieve necessary data (like PKCE verifier and original PDS host) from the state_store.
52+ let state_from_query = params.state.clone(); // For potential manual deletion later
53+54+ let oauth_client = get_oauth_client().await;
55+56+ match oauth_client.callback(params).await {
57+ Ok((session_data, _optional_dpop_nonce)) => {
58+ log::info!("OAuth callback successful, creating Agent...");
59+60+ // Create Agent from the session
61+ // Note: The concrete type of Agent might depend on how OAuthSession implements SessionManager
62+ // Or Agent::new might be flexible enough.
63+ let agent = Agent::new(session_data);
64+65+ // Get session info using the agent
66+ match agent.api.com.atproto.server.get_session().await {
67+ Ok(session_info) => {
68+ log::info!(
69+ "Successfully retrieved session info. DID: {:?}, Handle: {:?}",
70+ session_info.did,
71+ session_info.handle
72+ );
73+ // Extract the DID from the getSession response
74+ let user_did = session_info.did.clone(); // Clone the Did
75+76+ // Store the cloned DID
77+ auth_dispatch.set(AuthStore {
78+ did: Some(user_did),
79+ }); // No need to clone again here
80+81+ // Attempt to delete the used state from the state store (keep this logic)
82+ let state_store = IndexDBStateStore::new();
83+ if let Some(state_key_to_delete) = state_from_query {
84+ if let Err(e) = state_store.del(&state_key_to_delete).await {
85+ log::warn!(
86+ "Failed to delete state from store after use: {:?}",
87+ e
88+ );
89+ }
90+ } else {
91+ log::warn!(
92+ "No state found in CallbackParams to delete from store."
93+ );
94+ }
95+ navigator.push(&AppRoute::Home);
96+ }
97+ Err(e) => {
98+ log::error!(
99+ "Failed to get session info using agent after successful callback: {:?}",
100+ e
101+ );
102+ error_message
103+ .set(Some(format!("Failed to verify session: {:?}", e)));
104+ navigator.push(&AppRoute::Login);
105+ }
106+ }
107+ }
108+ Err(e) => {
109+ log::error!("OAuth callback processing failed: {:?}", e);
110+ error_message.set(Some(format!("Login failed during callback: {:?}", e)));
111+ navigator.push(&AppRoute::Login);
112+ }
113+ }
114+ });
115+ || ()
116+ });
117+ }
118+119+ html! {
120+ <div>
121+ if let Some(err) = &*error_message {
122+ <p class="text-red-500">{ format!("Error during login: {}", err) }</p>
123+ <p><Link<AppRoute> to={AppRoute::Login}>{ "Try logging in again" }</Link<AppRoute>></p>
124+ } else {
125+ <p>{ "Processing login, please wait..." }</p>
126+ }
127+ </div>
128+ }
129+}
+11
rust_demo_app/app_demo/src/pages/home.rs
···00000000000
···1+use yew::prelude::*;
2+3+#[function_component(HomePage)]
4+pub fn home_page() -> Html {
5+ html! {
6+ <div>
7+ <h1>{ "Welcome to the ATProto Counter!" }</h1>
8+ // Counter display and increment button will go here
9+ </div>
10+ }
11+}