Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql

feat(oauth): sync actor records on first login

+261
+191
dev-docs/plans/2025-12-02-oauth-actor-sync.md
···
··· 1 + # OAuth Actor Sync Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Sync an actor's ATProto records on first OAuth login so users see their existing data immediately. 6 + 7 + **Architecture:** Add a centralized `get_collection_ids()` helper to the backfill module, then call `actor_validator.ensure_actor_exists()` followed by `backfill.backfill_collections_for_actor()` in the OAuth callback after token exchange. Sync is blocking; errors are logged but don't fail login. 8 + 9 + **Tech Stack:** Gleam, SQLite, ATProto 10 + 11 + --- 12 + 13 + ## Task 1: Add `get_collection_ids` Helper to Backfill Module 14 + 15 + **Files:** 16 + - Modify: `server/src/backfill.gleam` 17 + - Test: Manual verification (existing tests cover partitioning logic) 18 + 19 + **Step 1: Add imports to backfill.gleam** 20 + 21 + Open `server/src/backfill.gleam` and ensure these imports exist at the top: 22 + 23 + ```gleam 24 + import database/repositories/config as config_repo 25 + import database/repositories/lexicons as lexicons_repo 26 + ``` 27 + 28 + **Step 2: Add `get_collection_ids` function** 29 + 30 + Add this function after the existing `nsid_matches_domain_authority` function (around line 430): 31 + 32 + ```gleam 33 + /// Get local and external collection IDs from configured lexicons 34 + /// Returns #(local_collection_ids, external_collection_ids) 35 + pub fn get_collection_ids( 36 + conn: sqlight.Connection, 37 + ) -> #(List(String), List(String)) { 38 + let domain_authority = case config_repo.get(conn, "domain_authority") { 39 + Ok(authority) -> authority 40 + Error(_) -> "" 41 + } 42 + 43 + case lexicons_repo.get_record_types(conn) { 44 + Ok(lexicons) -> { 45 + let #(local, external) = 46 + list.partition(lexicons, fn(lex) { 47 + nsid_matches_domain_authority(lex.id, domain_authority) 48 + }) 49 + #( 50 + list.map(local, fn(lex) { lex.id }), 51 + list.map(external, fn(lex) { lex.id }), 52 + ) 53 + } 54 + Error(_) -> #([], []) 55 + } 56 + } 57 + ``` 58 + 59 + **Step 3: Verify build succeeds** 60 + 61 + Run: `cd server && gleam build` 62 + Expected: Build succeeds with no errors 63 + 64 + **Step 4: Commit** 65 + 66 + ```bash 67 + git add server/src/backfill.gleam 68 + git commit -m "feat(backfill): add get_collection_ids helper for centralized collection retrieval" 69 + ``` 70 + 71 + --- 72 + 73 + ## Task 2: Add Actor Sync to OAuth Callback 74 + 75 + **Files:** 76 + - Modify: `server/src/handlers/oauth/atp_callback.gleam` 77 + 78 + **Step 1: Add imports to atp_callback.gleam** 79 + 80 + Open `server/src/handlers/oauth/atp_callback.gleam` and add these imports after the existing imports (around line 18): 81 + 82 + ```gleam 83 + import actor_validator 84 + import backfill 85 + import database/repositories/config as config_repo 86 + import logging 87 + ``` 88 + 89 + **Step 2: Add sync logic after token exchange** 90 + 91 + In the `handle_callback` function, find line 167 where `Ok(updated_session) -> {` begins. Add the sync logic immediately after this line, before the existing `// Clean up one-time-use oauth request` comment: 92 + 93 + ```gleam 94 + Ok(updated_session) -> { 95 + // Sync actor on first login (blocking) 96 + case updated_session.did { 97 + Some(did) -> { 98 + let plc_url = config_repo.get_plc_directory_url(conn) 99 + let #(collection_ids, external_collection_ids) = 100 + backfill.get_collection_ids(conn) 101 + 102 + case actor_validator.ensure_actor_exists(conn, did, plc_url) { 103 + Ok(True) -> { 104 + // New actor - backfill collections synchronously 105 + logging.log( 106 + logging.Info, 107 + "[oauth] Syncing new actor: " <> did, 108 + ) 109 + let _ = 110 + backfill.backfill_collections_for_actor( 111 + conn, 112 + did, 113 + collection_ids, 114 + external_collection_ids, 115 + plc_url, 116 + ) 117 + Nil 118 + } 119 + Ok(False) -> Nil 120 + // Existing actor, already synced 121 + Error(e) -> { 122 + logging.log( 123 + logging.Warning, 124 + "[oauth] Actor sync failed for " 125 + <> did 126 + <> ": " 127 + <> string.inspect(e), 128 + ) 129 + Nil 130 + } 131 + } 132 + } 133 + None -> Nil 134 + } 135 + 136 + // Clean up one-time-use oauth request (existing code continues here) 137 + ``` 138 + 139 + **Step 3: Verify build succeeds** 140 + 141 + Run: `cd server && gleam build` 142 + Expected: Build succeeds with no errors 143 + 144 + **Step 4: Run existing tests** 145 + 146 + Run: `cd server && gleam test` 147 + Expected: All tests pass 148 + 149 + **Step 5: Commit** 150 + 151 + ```bash 152 + git add server/src/handlers/oauth/atp_callback.gleam 153 + git commit -m "feat(oauth): sync actor records on first login 154 + 155 + Ensures new users see their existing ATProto data immediately after 156 + OAuth login. Sync is blocking but errors are logged and don't fail 157 + the login flow." 158 + ``` 159 + 160 + --- 161 + 162 + ## Task 3: Manual Integration Test 163 + 164 + **Step 1: Start the server** 165 + 166 + Run: `cd server && gleam run` 167 + 168 + **Step 2: Test OAuth flow with a new account** 169 + 170 + 1. Clear any existing session/actor data for your test DID 171 + 2. Go through OAuth login flow 172 + 3. Verify in logs: `[oauth] Syncing new actor: did:plc:...` 173 + 4. Verify records appear in database immediately after redirect 174 + 175 + **Step 3: Test OAuth flow with existing account** 176 + 177 + 1. Log in again with same account 178 + 2. Verify NO sync log appears (actor already exists) 179 + 3. Login should be fast (no blocking sync) 180 + 181 + --- 182 + 183 + ## Summary 184 + 185 + | Task | Description | Files | 186 + |------|-------------|-------| 187 + | 1 | Add `get_collection_ids` helper | `backfill.gleam` | 188 + | 2 | Add actor sync to OAuth callback | `atp_callback.gleam` | 189 + | 3 | Manual integration test | - | 190 + 191 + **Total changes:** ~50 lines across 2 files
+25
server/src/backfill.gleam
··· 422 string.starts_with(nsid, domain_authority <> ".") 423 } 424 425 /// Resolve a DID to get ATP data (PDS endpoint and handle) 426 pub fn resolve_did(did: String, plc_url: String) -> Result(AtprotoData, String) { 427 // Check if this is a did:web DID
··· 422 string.starts_with(nsid, domain_authority <> ".") 423 } 424 425 + /// Get local and external collection IDs from configured lexicons 426 + /// Returns #(local_collection_ids, external_collection_ids) 427 + pub fn get_collection_ids( 428 + conn: sqlight.Connection, 429 + ) -> #(List(String), List(String)) { 430 + let domain_authority = case config_repo.get(conn, "domain_authority") { 431 + Ok(authority) -> authority 432 + Error(_) -> "" 433 + } 434 + 435 + case lexicons.get_record_types(conn) { 436 + Ok(lexicon_list) -> { 437 + let #(local, external) = 438 + list.partition(lexicon_list, fn(lex) { 439 + nsid_matches_domain_authority(lex.id, domain_authority) 440 + }) 441 + #( 442 + list.map(local, fn(lex) { lex.id }), 443 + list.map(external, fn(lex) { lex.id }), 444 + ) 445 + } 446 + Error(_) -> #([], []) 447 + } 448 + } 449 + 450 /// Resolve a DID to get ATP data (PDS endpoint and handle) 451 pub fn resolve_did(did: String, plc_url: String) -> Result(AtprotoData, String) { 452 // Check if this is a did:web DID
+45
server/src/handlers/oauth/atp_callback.gleam
··· 1 /// ATP OAuth callback endpoint 2 /// Handles OAuth callback from ATProtocol PDS after user authorization 3 import database/repositories/oauth_atp_requests 4 import database/repositories/oauth_atp_sessions 5 import database/repositories/oauth_auth_requests ··· 15 import lib/oauth/atproto/bridge 16 import lib/oauth/did_cache 17 import lib/oauth/token_generator 18 import sqlight 19 import wisp 20 ··· 165 ) 166 } 167 Ok(updated_session) -> { 168 // Clean up one-time-use oauth request 169 let _ = oauth_atp_requests.delete(conn, state) 170
··· 1 /// ATP OAuth callback endpoint 2 /// Handles OAuth callback from ATProtocol PDS after user authorization 3 + import actor_validator 4 + import backfill 5 + import database/repositories/config as config_repo 6 import database/repositories/oauth_atp_requests 7 import database/repositories/oauth_atp_sessions 8 import database/repositories/oauth_auth_requests ··· 18 import lib/oauth/atproto/bridge 19 import lib/oauth/did_cache 20 import lib/oauth/token_generator 21 + import logging 22 import sqlight 23 import wisp 24 ··· 169 ) 170 } 171 Ok(updated_session) -> { 172 + // Sync actor on first login (blocking) 173 + case updated_session.did { 174 + Some(did) -> { 175 + let plc_url = config_repo.get_plc_directory_url(conn) 176 + let #(collection_ids, external_collection_ids) = 177 + backfill.get_collection_ids(conn) 178 + 179 + case actor_validator.ensure_actor_exists(conn, did, plc_url) { 180 + Ok(True) -> { 181 + // New actor - backfill collections synchronously 182 + logging.log( 183 + logging.Info, 184 + "[oauth] Syncing new actor: " <> did, 185 + ) 186 + let _ = 187 + backfill.backfill_collections_for_actor( 188 + conn, 189 + did, 190 + collection_ids, 191 + external_collection_ids, 192 + plc_url, 193 + ) 194 + Nil 195 + } 196 + Ok(False) -> Nil 197 + // Existing actor, already synced 198 + Error(e) -> { 199 + logging.log( 200 + logging.Warning, 201 + "[oauth] Actor sync failed for " 202 + <> did 203 + <> ": " 204 + <> string.inspect(e), 205 + ) 206 + Nil 207 + } 208 + } 209 + } 210 + None -> Nil 211 + } 212 + 213 // Clean up one-time-use oauth request 214 let _ = oauth_atp_requests.delete(conn, state) 215