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