···44edition = "2024"
5566[dependencies]
77+chrono = "0.4"
78clap = { version = "4.5.53", features = ["derive"] }
89dotenvy = "0.15.7"
910malfestio-core = { version = "0.1.0", path = "../core" }
+145
crates/cli/src/main.rs
···2323 #[arg(long)]
2424 db_url: Option<String>,
2525 },
2626+ /// Check OAuth flow and database state for a Bluesky handle
2727+ Check {
2828+ /// Bluesky handle to test (e.g., alice.bsky.social)
2929+ handle: String,
3030+ },
2631}
27322833#[tokio::main]
···3843 }
3944 Commands::Migrate { db_url } => {
4045 run_migrations(db_url.as_deref()).await?;
4646+ }
4747+ Commands::Check { handle } => {
4848+ check_flow(handle).await?;
4149 }
4250 }
4351···144152145153 Ok(())
146154}
155155+156156+async fn check_flow(handle: &str) -> malfestio_core::Result<()> {
157157+ println!("Checking OAuth flow for {}...\n", handle);
158158+159159+ // Get database URL
160160+ let db_url = std::env::var("DB_URL")
161161+ .or_else(|_| std::env::var("DATABASE_URL"))
162162+ .map_err(|_| malfestio_core::Error::InvalidArgument("DB_URL or DATABASE_URL not set".to_string()))?;
163163+164164+ // Test database connection
165165+ print!("• Testing database connection... ");
166166+ let (client, connection) = tokio_postgres::connect(&db_url, NoTls)
167167+ .await
168168+ .map_err(|e| malfestio_core::Error::Database(format!("Failed to connect: {}", e)))?;
169169+170170+ tokio::spawn(async move {
171171+ if let Err(e) = connection.await {
172172+ eprintln!("Database connection error: {}", e);
173173+ }
174174+ });
175175+176176+ println!("✓ Connected");
177177+178178+ let resolver = malfestio_server::oauth::resolver::IdentityResolver::new();
179179+180180+ print!("• Resolving handle to DID... ");
181181+ let did = match resolver.resolve_handle(handle).await {
182182+ Ok(did) => {
183183+ println!("✓ {}", did);
184184+ did
185185+ }
186186+ Err(e) => {
187187+ println!("✗ Failed: {}", e);
188188+ return Err(malfestio_core::Error::Other(format!("Handle resolution failed: {}", e)));
189189+ }
190190+ };
191191+192192+ print!("• Resolving DID to PDS... ");
193193+ let _resolved = match resolver.resolve_did(&did).await {
194194+ Ok(resolved) => {
195195+ println!("✓ {}", resolved.pds_url);
196196+ resolved
197197+ }
198198+ Err(e) => {
199199+ println!("✗ Failed: {}", e);
200200+ return Err(malfestio_core::Error::Other(format!("DID resolution failed: {}", e)));
201201+ }
202202+ };
203203+204204+ print!("• Checking OAuth tokens... ");
205205+ let token_row = client
206206+ .query_opt(
207207+ "SELECT did, pds_url, created_at, updated_at FROM oauth_tokens WHERE did = $1",
208208+ &[&did],
209209+ )
210210+ .await
211211+ .map_err(|e| malfestio_core::Error::Database(format!("Token query failed: {}", e)))?;
212212+213213+ if let Some(row) = token_row {
214214+ let updated_at: chrono::DateTime<chrono::Utc> = row.get(3);
215215+ println!("✓ Found (last updated: {})", updated_at.format("%Y-%m-%d %H:%M:%S UTC"));
216216+ } else {
217217+ println!("✗ Not found");
218218+ println!("\nℹ No OAuth tokens stored yet. Complete OAuth login first:");
219219+ println!(" 1. Start server: just start");
220220+ println!(" 2. Start frontend: just web-dev");
221221+ println!(" 3. Navigate to http://localhost:3000/login");
222222+ println!(" 4. Enter handle: {}", handle);
223223+ return Ok(());
224224+ }
225225+226226+ print!("• Checking indexed decks... ");
227227+ let deck_rows = client
228228+ .query(
229229+ "SELECT at_uri, title, indexed_at FROM indexed_decks WHERE did = $1 ORDER BY indexed_at DESC LIMIT 5",
230230+ &[&did],
231231+ )
232232+ .await
233233+ .map_err(|e| malfestio_core::Error::Database(format!("Deck query failed: {}", e)))?;
234234+235235+ if deck_rows.is_empty() {
236236+ println!("0 decks");
237237+ } else {
238238+ println!("{} deck(s)", deck_rows.len());
239239+ for row in &deck_rows {
240240+ let at_uri: String = row.get(0);
241241+ let title: Option<String> = row.get(1);
242242+ let indexed_at: chrono::DateTime<chrono::Utc> = row.get(2);
243243+ let time_ago = format_time_ago(indexed_at);
244244+ println!(" - {} ({})", title.unwrap_or_else(|| "Untitled".to_string()), time_ago);
245245+ println!(" {}", at_uri);
246246+ }
247247+ }
248248+249249+ print!("• Checking indexed cards... ");
250250+ let card_count: i64 = client
251251+ .query_one("SELECT COUNT(*) FROM indexed_cards WHERE did = $1", &[&did])
252252+ .await
253253+ .map_err(|e| malfestio_core::Error::Database(format!("Card count query failed: {}", e)))?
254254+ .get(0);
255255+256256+ println!("{} card(s)", card_count);
257257+258258+ print!("• Checking indexed notes... ");
259259+ let note_count: i64 = client
260260+ .query_one("SELECT COUNT(*) FROM indexed_notes WHERE did = $1", &[&did])
261261+ .await
262262+ .map_err(|e| malfestio_core::Error::Database(format!("Note count query failed: {}", e)))?
263263+ .get(0);
264264+265265+ println!("{} note(s)", note_count);
266266+267267+ println!("\n✓ Status: Ready for testing");
268268+ println!("\nNext steps:");
269269+ println!(" - Publish content via UI to see it indexed");
270270+ println!(" - Check Bluesky profile: https://bsky.app/profile/{}", handle);
271271+ println!(" - Inspect records: https://pdsls.dev/at/{}", did);
272272+273273+ Ok(())
274274+}
275275+276276+fn format_time_ago(timestamp: chrono::DateTime<chrono::Utc>) -> String {
277277+ let now = chrono::Utc::now();
278278+ let duration = now.signed_duration_since(timestamp);
279279+280280+ if duration.num_seconds() < 60 {
281281+ format!("{} seconds ago", duration.num_seconds())
282282+ } else if duration.num_minutes() < 60 {
283283+ format!("{} minutes ago", duration.num_minutes())
284284+ } else if duration.num_hours() < 24 {
285285+ format!("{} hours ago", duration.num_hours())
286286+ } else if duration.num_days() < 30 {
287287+ format!("{} days ago", duration.num_days())
288288+ } else {
289289+ format!("{} months ago", duration.num_days() / 30)
290290+ }
291291+}
+74-11
docs/local-dev.md
···11111212### Bluesky Account Setup
13131414-1. Create a Bluesky account at <https://bsky.app>
1515-2. Generate an App Password (Settings → App Passwords)
1616-3. Configure `.env` with your credentials:
1414+1. Create a Bluesky account at <https://bsky.app> (you'll use this for OAuth testing)
1515+1616+### Environment Configuration
1717+1818+Copy the template and configure for your environment:
17191820```bash
1919-APP_USERNAME=your-handle.bsky.social
2020-APP_PASSWORD=your-app-password-here
2121-DB_URL="postgres://postgres:postgres@localhost:5432/malfestio_dev?sslmode=disable"
2121+cp .env.example .env
2222```
2323+2424+For local development, the defaults in `.env.example` work out of the box. You only need to ensure your PostgreSQL connection string is correct.
23252426## Testing OAuth Flow
2527···82843. Check your Bluesky profile at <https://bsky.app> to see the published record
83854. Verify record appears in your AT Protocol repository
84868585-## Environment Variables
8787+## Verifying Your Setup
8888+8989+### Check OAuth Tokens
9090+9191+After successful login, verify tokens were stored:
9292+9393+```sql
9494+SELECT
9595+ did,
9696+ pds_url,
9797+ LEFT(access_token, 20) || '...' as token_preview,
9898+ created_at,
9999+ updated_at
100100+FROM oauth_tokens
101101+WHERE did = 'your-did-here';
102102+```
103103+104104+Replace `'your-did-here'` with the DID from your login success page.
105105+106106+### Check Indexed Records
107107+108108+After publishing content, verify firehose indexing:
109109+110110+```sql
111111+-- Check indexed decks
112112+SELECT at_uri, title, indexed_at
113113+FROM indexed_decks
114114+WHERE did = 'your-did-here'
115115+ORDER BY indexed_at DESC
116116+LIMIT 10;
117117+118118+-- Check indexed cards
119119+SELECT at_uri, front_content, indexed_at
120120+FROM indexed_cards
121121+WHERE did = 'your-did-here'
122122+ORDER BY indexed_at DESC
123123+LIMIT 10;
124124+```
125125+126126+Note: Indexing may take 5-10 seconds after publishing.
127127+128128+### Diagnostic Command
129129+130130+Run this command to check handle resolution and database state:
131131+132132+```bash
133133+just verify your-handle.bsky.social
134134+```
135135+136136+This will verify:
137137+138138+- Database connection
139139+- Handle → DID resolution
140140+- DID → PDS URL resolution
141141+- OAuth token status
142142+- Indexed content count
143143+144144+## Environment Variables Reference
8614587146### Required
8814789148```bash
9090-APP_USERNAME=your-handle.bsky.social
9191-APP_PASSWORD=your-app-password
92149DB_URL="postgres://postgres:postgres@localhost:5432/malfestio_dev?sslmode=disable"
93150```
9415195152### Optional
9615397154```bash
9898-# Server configuration
155155+# OAuth Client Configuration
156156+APP_URL=http://localhost:3000 # OAuth callback URL
157157+APP_NAME=Malfestio # App display name
158158+159159+# Server Configuration
99160SERVER_HOST=127.0.0.1
100161SERVER_PORT=8080
101162102102-# Frontend proxy
163163+# Frontend Configuration
103164VITE_API_URL=http://localhost:8080
104165105166# Logging
106167RUST_LOG=info,malfestio_server=debug
107168```
169169+170170+See `.env.example` for a complete template.
108171109172## Additional Resources
110173
+3-10
justfile
···6464migrate:
6565 cargo run --bin malfestio-cli migrate
66666767-# Setup and test OAuth flow with real Bluesky account
6868-test-oauth:
6969- @echo "Testing OAuth with Bluesky account..."
7070- @echo "1. Ensure PostgreSQL is running"
7171- @echo "2. Running migrations..."
7272- @just migrate
7373- @echo "3. Start backend with: just start"
7474- @echo "4. Start frontend with: just web-dev"
7575- @echo "5. Navigate to http://localhost:3000/login"
7676- @echo "6. Enter your Bluesky handle from .env"
6767+# Test handle and DID resolution for a Bluesky account
6868+verify HANDLE:
6969+ cargo run --bin malfestio-cli check {{HANDLE}}
77707871# Clean build artifacts
7972clean: