···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}
4240000000000000000000000000425/// Resolve a DID to get ATP data (PDS endpoint and handle)
426pub 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}
424425+/// 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)
451pub 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
0003import database/repositories/oauth_atp_requests
4import database/repositories/oauth_atp_sessions
5import database/repositories/oauth_auth_requests
···15import lib/oauth/atproto/bridge
16import lib/oauth/did_cache
17import lib/oauth/token_generator
018import sqlight
19import wisp
20···165 )
166 }
167 Ok(updated_session) -> {
00000000000000000000000000000000000000000168 // 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
6import database/repositories/oauth_atp_requests
7import database/repositories/oauth_atp_sessions
8import database/repositories/oauth_auth_requests
···18import lib/oauth/atproto/bridge
19import lib/oauth/did_cache
20import lib/oauth/token_generator
21+import logging
22import sqlight
23import 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