A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

update privacy policy, add exporting/deleting bluesky posts as part of userdata

+642 -18
+5 -1
pkg/appview/handlers/delete.go
··· 43 43 CrewDeleted bool `json:"crew_deleted,omitempty"` 44 44 LayersDeleted int `json:"layers_deleted,omitempty"` 45 45 StatsDeleted int `json:"stats_deleted,omitempty"` 46 + PostsDeleted int `json:"posts_deleted,omitempty"` 46 47 } 47 48 48 49 // DeleteAccountHandler handles GDPR account deletion requests ··· 267 268 CrewDeleted bool `json:"crew_deleted"` 268 269 LayersDeleted int `json:"layers_deleted"` 269 270 StatsDeleted int `json:"stats_deleted"` 271 + PostsDeleted int `json:"posts_deleted"` 270 272 } 271 273 if err := json.NewDecoder(resp.Body).Decode(&holdResponse); err != nil { 272 274 result.Error = fmt.Sprintf("Failed to parse response: %v", err) ··· 278 280 result.CrewDeleted = holdResponse.CrewDeleted 279 281 result.LayersDeleted = holdResponse.LayersDeleted 280 282 result.StatsDeleted = holdResponse.StatsDeleted 283 + result.PostsDeleted = holdResponse.PostsDeleted 281 284 282 285 slog.Debug("Successfully deleted data from hold", 283 286 "component", "delete", ··· 285 288 "user_did", user.DID, 286 289 "crew_deleted", holdResponse.CrewDeleted, 287 290 "layers_deleted", holdResponse.LayersDeleted, 288 - "stats_deleted", holdResponse.StatsDeleted) 291 + "stats_deleted", holdResponse.StatsDeleted, 292 + "posts_deleted", holdResponse.PostsDeleted) 289 293 290 294 return result 291 295 }
+80 -8
pkg/appview/templates/pages/privacy.html
··· 21 21 22 22 <h3>Data Stored on Our Infrastructure</h3> 23 23 24 - <p><strong>Layer Records:</strong> We maintain records on our own PDS that reference container image layers you publish. These records are public and link your AT Protocol identity (DID) to content-addressed SHA identifiers.</p> 24 + <p><strong>Layer Records:</strong> Our hold services (e.g., <code>hold01.atcr.io</code>) maintain records in their embedded PDS that reference container image layers you publish. These records are public and link your AT Protocol identity (DID) to content-addressed SHA identifiers.</p> 25 25 26 26 <p><strong>OCI Blobs:</strong> Container image layers are stored in our object storage (S3). These blobs are content-addressed and deduplicated—meaning identical layers uploaded by different users are stored only once.</p> 27 27 ··· 44 44 </div> 45 45 46 46 <div class="legal-section"> 47 + <h2>Our Services and Their Data</h2> 48 + 49 + <p>AT Container Registry consists of multiple services, each with distinct data responsibilities:</p> 50 + 51 + <h3>AppView (atcr.io)</h3> 52 + <p>The registry frontend you interact with directly. Stores:</p> 53 + <ul> 54 + <li>OAuth sessions and tokens for authentication</li> 55 + <li>Device tokens for the Docker credential helper</li> 56 + <li>Web UI sessions</li> 57 + <li>Cached metadata from your PDS (indexes for search and display)</li> 58 + </ul> 59 + 60 + <h3>ATCR-Hosted Hold Services</h3> 61 + <p>Storage backends we operate (e.g., <code>hold01.atcr.io</code>). Each hold has an embedded PDS and stores:</p> 62 + <ul> 63 + <li>OCI blobs (container image layers) in object storage</li> 64 + <li>Layer records in the hold's embedded PDS linking your DID to blob references</li> 65 + <li>Crew membership records for access control</li> 66 + </ul> 67 + <p>Hold services on <code>*.atcr.io</code> domains are operated by us and covered by this policy.</p> 68 + 69 + <h3>User-Deployed Hold Services (BYOS)</h3> 70 + <p>You may use "Bring Your Own Storage" by deploying your own hold service. Data on user-deployed holds is governed by that operator's privacy policy, not ours. We can request deletion on your behalf but cannot guarantee it for services we do not control.</p> 71 + </div> 72 + 73 + <div class="legal-section"> 47 74 <h2>Data Sharing and Deduplication</h2> 48 75 49 76 <p>OCI container images use content-addressable storage. When you push an image layer, it is identified by its cryptographic hash (SHA256). If another user pushes an identical layer, both users reference the same underlying blob. This is standard practice for container registries and enables efficient storage and distribution.</p> ··· 75 102 <h3>Right to Erasure ("Right to be Forgotten")</h3> 76 103 <p>You may request deletion of your data via the account settings page. Due to our technical architecture, deletion works as follows:</p> 77 104 78 - <p><strong>Immediately deleted:</strong></p> 105 + <p><strong>Immediately deleted from AppView:</strong></p> 79 106 <ul> 80 - <li>Layer records on our PDS that reference your DID</li> 81 107 <li>OAuth tokens, web UI sessions, and device tokens</li> 82 - <li>Cached PDS data</li> 108 + <li>Cached PDS data (manifest and tag indexes)</li> 83 109 <li>Server logs containing your identifiers (deleted or anonymized, if retained)</li> 84 110 </ul> 85 111 86 - <p><strong>Deleted within 30 days:</strong></p> 112 + <p><strong>Immediately deleted from ATCR-hosted holds:</strong></p> 113 + <ul> 114 + <li>Layer records in the hold's embedded PDS that reference your DID</li> 115 + <li>Crew membership records</li> 116 + </ul> 117 + 118 + <p><strong>Deleted within 30 days from ATCR-hosted holds:</strong></p> 87 119 <ul> 88 - <li>OCI blobs in our object storage that are no longer referenced by any user after your records are removed (via our orphan blob pruning process)</li> 120 + <li>OCI blobs in object storage that are no longer referenced by any user (via garbage collection)</li> 121 + </ul> 122 + 123 + <p><strong>User-deployed holds:</strong></p> 124 + <ul> 125 + <li>We attempt to delete your data via API, but success depends on hold availability</li> 126 + <li>Data on holds we do not operate is governed by that operator's policies</li> 89 127 </ul> 90 128 91 129 <p><strong>Cannot be deleted by us:</strong></p> ··· 174 212 <thead> 175 213 <tr> 176 214 <th>Data Type</th> 215 + <th>Service</th> 177 216 <th>Retention Period</th> 178 217 </tr> 179 218 </thead> 180 219 <tbody> 181 220 <tr> 182 221 <td>OAuth tokens</td> 222 + <td>AppView</td> 183 223 <td>Until revoked or logout</td> 184 224 </tr> 185 225 <tr> 186 226 <td>Web UI session tokens</td> 227 + <td>AppView</td> 187 228 <td>Until logout or expiration</td> 188 229 </tr> 189 230 <tr> 190 231 <td>Device tokens (credential helper)</td> 232 + <td>AppView</td> 191 233 <td>Until revoked by user</td> 192 234 </tr> 193 235 <tr> 194 236 <td>Cached PDS data</td> 237 + <td>AppView</td> 195 238 <td>Refreshed periodically; deleted on account deletion</td> 196 239 </tr> 197 240 <tr> 198 241 <td>Server logs</td> 242 + <td>AppView</td> 199 243 <td>Currently ephemeral; this policy will be updated if log retention is implemented</td> 200 244 </tr> 201 245 <tr> 202 - <td>Layer records (our PDS)</td> 246 + <td>Layer records</td> 247 + <td>Hold PDS</td> 203 248 <td>Until you request deletion</td> 204 249 </tr> 205 250 <tr> 206 251 <td>OCI blobs</td> 207 - <td>Until no longer referenced (pruned monthly)</td> 252 + <td>Hold Storage</td> 253 + <td>Until no longer referenced (pruned within 30 days)</td> 208 254 </tr> 209 255 </tbody> 210 256 </table> ··· 221 267 <li><strong>Content-addressed storage.</strong> OCI blobs are identified by their cryptographic hash. This means blob data is inherently pseudonymous—it cannot be attributed to you without the corresponding records that reference it.</li> 222 268 <li><strong>Deletion limitations.</strong> Because AT Protocol is distributed, we cannot guarantee that copies of public records have not been made by other participants in the network. We can only delete data on infrastructure we control.</li> 223 269 </ol> 270 + </div> 271 + 272 + <div class="legal-section"> 273 + <h2>Bring Your Own Storage (BYOS)</h2> 274 + 275 + <p>AT Container Registry supports "Bring Your Own Storage" where users can deploy their own hold services to store container image blobs. This section explains how BYOS affects your privacy rights.</p> 276 + 277 + <h3>ATCR-Hosted Holds</h3> 278 + <p>Hold services on <code>*.atcr.io</code> domains (e.g., <code>hold01.atcr.io</code>) are operated by us and fully covered by this privacy policy. We can fulfill all data access, export, and deletion requests for these services.</p> 279 + 280 + <h3>User-Deployed Holds</h3> 281 + <p>If you use a hold service not operated by us:</p> 282 + <ul> 283 + <li>That hold's data practices are governed by its operator's privacy policy, not ours</li> 284 + <li>When you request account deletion, we attempt to delete your data from all holds via API</li> 285 + <li>We cannot guarantee deletion for holds that are offline or refuse the request</li> 286 + <li>You should contact that hold's operator directly for data requests we cannot fulfill</li> 287 + </ul> 288 + 289 + <h3>If You Operate a Hold</h3> 290 + <p>If you deploy your own hold service and allow other users to store data on it, you become a data controller for that data under GDPR/CCPA. You are responsible for:</p> 291 + <ul> 292 + <li>Responding to deletion requests from users of your hold</li> 293 + <li>Implementing appropriate data retention policies</li> 294 + <li>Publishing your own privacy policy if required by law</li> 295 + </ul> 224 296 </div> 225 297 226 298 <div class="legal-section">
+184 -1
pkg/hold/pds/delete.go
··· 1 1 package pds 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "fmt" 6 7 "log/slog" 7 8 8 9 "atcr.io/pkg/atproto" 10 + bsky "github.com/bluesky-social/indigo/api/bsky" 9 11 ) 10 12 11 13 // UserDeleteResult contains the results of deleting a user's data from the hold ··· 13 15 CrewDeleted bool `json:"crew_deleted"` 14 16 LayersDeleted int `json:"layers_deleted"` 15 17 StatsDeleted int `json:"stats_deleted"` 18 + PostsDeleted int `json:"posts_deleted"` 16 19 } 17 20 18 21 // DeleteUserData deletes all data for a user from the hold's PDS. ··· 20 23 // - Crew record (if user is a crew member) 21 24 // - Layer records (where userDid matches) 22 25 // - Stats records (where ownerDid matches) 26 + // - Bluesky posts that mention the user (for GDPR compliance) 23 27 // 24 28 // NOTE: This does NOT delete the captain record if the user is the hold owner. 25 29 // NOTE: This does NOT delete actual blob data from S3 - only the PDS records. ··· 60 64 } 61 65 result.StatsDeleted = statsDeleted 62 66 67 + // 4. Delete Bluesky posts that mention this user (GDPR compliance) 68 + postsDeleted, err := p.deleteBlueskyPosts(ctx, userDID) 69 + if err != nil { 70 + slog.Warn("Failed to delete bluesky posts", 71 + "user_did", userDID, 72 + "error", err) 73 + // Continue - this is best-effort 74 + } 75 + result.PostsDeleted = postsDeleted 76 + 63 77 slog.Info("User data deletion complete", 64 78 "user_did", userDID, 65 79 "hold_did", p.DID(), 66 80 "crew_deleted", result.CrewDeleted, 67 81 "layers_deleted", result.LayersDeleted, 68 - "stats_deleted", result.StatsDeleted) 82 + "stats_deleted", result.StatsDeleted, 83 + "posts_deleted", result.PostsDeleted) 69 84 70 85 return result, nil 71 86 } ··· 190 205 191 206 return deleted, nil 192 207 } 208 + 209 + // deleteBlueskyPosts removes all Bluesky posts that mention a user's DID 210 + // Posts store mentions in facets: Facets[].Features[].RichtextFacet_Mention.Did 211 + func (p *HoldPDS) deleteBlueskyPosts(ctx context.Context, userDID string) (int, error) { 212 + if p.recordsIndex == nil { 213 + return 0, fmt.Errorf("records index not available") 214 + } 215 + 216 + deleted := 0 217 + cursor := "" 218 + batchSize := 100 219 + 220 + for { 221 + // Get all Bluesky posts 222 + records, nextCursor, err := p.recordsIndex.ListRecords(atproto.BskyPostCollection, batchSize, cursor, false) 223 + if err != nil { 224 + return deleted, fmt.Errorf("failed to list bluesky posts: %w", err) 225 + } 226 + 227 + for _, rec := range records { 228 + // Get the record bytes to check the facets 229 + recordPath := rec.Collection + "/" + rec.Rkey 230 + _, recBytes, err := p.GetRecordBytes(ctx, recordPath) 231 + if err != nil { 232 + slog.Warn("Failed to get post record bytes", 233 + "rkey", rec.Rkey, 234 + "error", err) 235 + continue 236 + } 237 + 238 + if recBytes == nil { 239 + continue 240 + } 241 + 242 + // Parse as FeedPost to check facets 243 + var post bsky.FeedPost 244 + if err := post.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil { 245 + slog.Warn("Failed to unmarshal post record", 246 + "rkey", rec.Rkey, 247 + "error", err) 248 + continue 249 + } 250 + 251 + // Check if any facet mentions this user's DID 252 + if !postMentionsUser(&post, userDID) { 253 + continue 254 + } 255 + 256 + // Delete from repo (MST) 257 + err = p.repomgr.DeleteRecord(ctx, p.uid, atproto.BskyPostCollection, rec.Rkey) 258 + if err != nil { 259 + slog.Warn("Failed to delete bluesky post from repo", 260 + "rkey", rec.Rkey, 261 + "error", err) 262 + continue 263 + } 264 + 265 + // Delete from index 266 + err = p.recordsIndex.DeleteRecord(atproto.BskyPostCollection, rec.Rkey) 267 + if err != nil { 268 + slog.Warn("Failed to delete bluesky post from index", 269 + "rkey", rec.Rkey, 270 + "error", err) 271 + } 272 + 273 + deleted++ 274 + } 275 + 276 + if nextCursor == "" { 277 + break 278 + } 279 + cursor = nextCursor 280 + } 281 + 282 + if deleted > 0 { 283 + slog.Debug("Deleted bluesky posts mentioning user", "user_did", userDID, "count", deleted) 284 + } 285 + 286 + return deleted, nil 287 + } 288 + 289 + // postMentionsUser checks if a post's facets contain a mention of the given DID 290 + func postMentionsUser(post *bsky.FeedPost, userDID string) bool { 291 + if post.Facets == nil { 292 + return false 293 + } 294 + 295 + for _, facet := range post.Facets { 296 + if facet.Features == nil { 297 + continue 298 + } 299 + for _, feature := range facet.Features { 300 + if feature.RichtextFacet_Mention != nil && feature.RichtextFacet_Mention.Did == userDID { 301 + return true 302 + } 303 + } 304 + } 305 + return false 306 + } 307 + 308 + // BlueskyPostInfo represents a Bluesky post for export 309 + type BlueskyPostInfo struct { 310 + Rkey string 311 + Text string 312 + CreatedAt string 313 + } 314 + 315 + // ListBlueskyPostsForUser returns all Bluesky posts that mention a user's DID 316 + func (p *HoldPDS) ListBlueskyPostsForUser(ctx context.Context, userDID string) ([]BlueskyPostInfo, error) { 317 + if p.recordsIndex == nil { 318 + return nil, fmt.Errorf("records index not available") 319 + } 320 + 321 + var posts []BlueskyPostInfo 322 + cursor := "" 323 + batchSize := 100 324 + 325 + for { 326 + // Get all Bluesky posts 327 + records, nextCursor, err := p.recordsIndex.ListRecords(atproto.BskyPostCollection, batchSize, cursor, false) 328 + if err != nil { 329 + return posts, fmt.Errorf("failed to list bluesky posts: %w", err) 330 + } 331 + 332 + for _, rec := range records { 333 + // Get the record bytes to check the facets 334 + recordPath := rec.Collection + "/" + rec.Rkey 335 + _, recBytes, err := p.GetRecordBytes(ctx, recordPath) 336 + if err != nil { 337 + slog.Warn("Failed to get post record bytes for export", 338 + "rkey", rec.Rkey, 339 + "error", err) 340 + continue 341 + } 342 + 343 + if recBytes == nil { 344 + continue 345 + } 346 + 347 + // Parse as FeedPost to check facets 348 + var post bsky.FeedPost 349 + if err := post.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil { 350 + slog.Warn("Failed to unmarshal post record for export", 351 + "rkey", rec.Rkey, 352 + "error", err) 353 + continue 354 + } 355 + 356 + // Check if any facet mentions this user's DID 357 + if !postMentionsUser(&post, userDID) { 358 + continue 359 + } 360 + 361 + posts = append(posts, BlueskyPostInfo{ 362 + Rkey: rec.Rkey, 363 + Text: post.Text, 364 + CreatedAt: post.CreatedAt, 365 + }) 366 + } 367 + 368 + if nextCursor == "" { 369 + break 370 + } 371 + cursor = nextCursor 372 + } 373 + 374 + return posts, nil 375 + }
+337
pkg/hold/pds/delete_test.go
··· 1 + package pds 2 + 3 + import ( 4 + "testing" 5 + 6 + "atcr.io/pkg/atproto" 7 + bsky "github.com/bluesky-social/indigo/api/bsky" 8 + ) 9 + 10 + func TestPostMentionsUser(t *testing.T) { 11 + tests := []struct { 12 + name string 13 + post *bsky.FeedPost 14 + userDID string 15 + expected bool 16 + }{ 17 + { 18 + name: "post mentions user", 19 + post: &bsky.FeedPost{ 20 + Text: "@alice.bsky.social pushed myapp:latest", 21 + Facets: []*bsky.RichtextFacet{{ 22 + Index: &bsky.RichtextFacet_ByteSlice{ 23 + ByteStart: 0, 24 + ByteEnd: 20, 25 + }, 26 + Features: []*bsky.RichtextFacet_Features_Elem{{ 27 + RichtextFacet_Mention: &bsky.RichtextFacet_Mention{ 28 + Did: "did:plc:alice123", 29 + }, 30 + }}, 31 + }}, 32 + }, 33 + userDID: "did:plc:alice123", 34 + expected: true, 35 + }, 36 + { 37 + name: "post mentions different user", 38 + post: &bsky.FeedPost{ 39 + Text: "@bob.bsky.social pushed myapp:latest", 40 + Facets: []*bsky.RichtextFacet{{ 41 + Index: &bsky.RichtextFacet_ByteSlice{ 42 + ByteStart: 0, 43 + ByteEnd: 18, 44 + }, 45 + Features: []*bsky.RichtextFacet_Features_Elem{{ 46 + RichtextFacet_Mention: &bsky.RichtextFacet_Mention{ 47 + Did: "did:plc:bob456", 48 + }, 49 + }}, 50 + }}, 51 + }, 52 + userDID: "did:plc:alice123", 53 + expected: false, 54 + }, 55 + { 56 + name: "post with no facets", 57 + post: &bsky.FeedPost{ 58 + Text: "Just a regular post", 59 + Facets: nil, 60 + }, 61 + userDID: "did:plc:alice123", 62 + expected: false, 63 + }, 64 + { 65 + name: "post with empty facets", 66 + post: &bsky.FeedPost{ 67 + Text: "Just a regular post", 68 + Facets: []*bsky.RichtextFacet{}, 69 + }, 70 + userDID: "did:plc:alice123", 71 + expected: false, 72 + }, 73 + { 74 + name: "post with link facet only (no mention)", 75 + post: &bsky.FeedPost{ 76 + Text: "Check out https://example.com", 77 + Facets: []*bsky.RichtextFacet{{ 78 + Index: &bsky.RichtextFacet_ByteSlice{ 79 + ByteStart: 10, 80 + ByteEnd: 30, 81 + }, 82 + Features: []*bsky.RichtextFacet_Features_Elem{{ 83 + RichtextFacet_Link: &bsky.RichtextFacet_Link{ 84 + Uri: "https://example.com", 85 + }, 86 + }}, 87 + }}, 88 + }, 89 + userDID: "did:plc:alice123", 90 + expected: false, 91 + }, 92 + { 93 + name: "post with multiple mentions - match second", 94 + post: &bsky.FeedPost{ 95 + Text: "@bob.bsky.social and @alice.bsky.social pushed", 96 + Facets: []*bsky.RichtextFacet{ 97 + { 98 + Index: &bsky.RichtextFacet_ByteSlice{ 99 + ByteStart: 0, 100 + ByteEnd: 18, 101 + }, 102 + Features: []*bsky.RichtextFacet_Features_Elem{{ 103 + RichtextFacet_Mention: &bsky.RichtextFacet_Mention{ 104 + Did: "did:plc:bob456", 105 + }, 106 + }}, 107 + }, 108 + { 109 + Index: &bsky.RichtextFacet_ByteSlice{ 110 + ByteStart: 23, 111 + ByteEnd: 43, 112 + }, 113 + Features: []*bsky.RichtextFacet_Features_Elem{{ 114 + RichtextFacet_Mention: &bsky.RichtextFacet_Mention{ 115 + Did: "did:plc:alice123", 116 + }, 117 + }}, 118 + }, 119 + }, 120 + }, 121 + userDID: "did:plc:alice123", 122 + expected: true, 123 + }, 124 + { 125 + name: "facet with nil features", 126 + post: &bsky.FeedPost{ 127 + Text: "Some post", 128 + Facets: []*bsky.RichtextFacet{{ 129 + Index: &bsky.RichtextFacet_ByteSlice{ 130 + ByteStart: 0, 131 + ByteEnd: 4, 132 + }, 133 + Features: nil, 134 + }}, 135 + }, 136 + userDID: "did:plc:alice123", 137 + expected: false, 138 + }, 139 + } 140 + 141 + for _, tt := range tests { 142 + t.Run(tt.name, func(t *testing.T) { 143 + result := postMentionsUser(tt.post, tt.userDID) 144 + if result != tt.expected { 145 + t.Errorf("postMentionsUser() = %v, want %v", result, tt.expected) 146 + } 147 + }) 148 + } 149 + } 150 + 151 + func TestDeleteBlueskyPosts_NoPosts(t *testing.T) { 152 + // Create a test PDS with records index 153 + pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner") 154 + defer cleanup() 155 + 156 + ctx := sharedCtx 157 + 158 + // Delete posts for a user that has no posts 159 + deleted, err := pds.deleteBlueskyPosts(ctx, "did:plc:nonexistent") 160 + if err != nil { 161 + t.Fatalf("deleteBlueskyPosts() error = %v", err) 162 + } 163 + 164 + if deleted != 0 { 165 + t.Errorf("deleteBlueskyPosts() deleted = %d, want 0", deleted) 166 + } 167 + } 168 + 169 + func TestListBlueskyPostsForUser_NoPosts(t *testing.T) { 170 + // Create a test PDS with records index 171 + pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner") 172 + defer cleanup() 173 + 174 + ctx := sharedCtx 175 + 176 + // List posts for a user that has no posts 177 + posts, err := pds.ListBlueskyPostsForUser(ctx, "did:plc:nonexistent") 178 + if err != nil { 179 + t.Fatalf("ListBlueskyPostsForUser() error = %v", err) 180 + } 181 + 182 + if len(posts) != 0 { 183 + t.Errorf("ListBlueskyPostsForUser() returned %d posts, want 0", len(posts)) 184 + } 185 + } 186 + 187 + func TestDeleteAndListBlueskyPosts_WithPosts(t *testing.T) { 188 + // Create a test PDS with records index 189 + pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner") 190 + defer cleanup() 191 + 192 + ctx := sharedCtx 193 + 194 + // Create a test post that mentions alice 195 + aliceDID := "did:plc:alice123" 196 + post := &bsky.FeedPost{ 197 + LexiconTypeID: atproto.BskyPostCollection, 198 + Text: "@alice.bsky.social pushed myapp:latest", 199 + Facets: []*bsky.RichtextFacet{{ 200 + Index: &bsky.RichtextFacet_ByteSlice{ 201 + ByteStart: 0, 202 + ByteEnd: 20, 203 + }, 204 + Features: []*bsky.RichtextFacet_Features_Elem{{ 205 + RichtextFacet_Mention: &bsky.RichtextFacet_Mention{ 206 + Did: aliceDID, 207 + }, 208 + }}, 209 + }}, 210 + CreatedAt: "2025-01-01T00:00:00Z", 211 + } 212 + 213 + // Create the post in the PDS 214 + rkey, _, err := pds.repomgr.CreateRecord(ctx, pds.uid, atproto.BskyPostCollection, post) 215 + if err != nil { 216 + t.Fatalf("Failed to create test post: %v", err) 217 + } 218 + 219 + // Index the record 220 + if pds.recordsIndex != nil { 221 + err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID) 222 + if err != nil { 223 + t.Fatalf("Failed to index test post: %v", err) 224 + } 225 + } 226 + 227 + // List posts for alice - should find 1 228 + posts, err := pds.ListBlueskyPostsForUser(ctx, aliceDID) 229 + if err != nil { 230 + t.Fatalf("ListBlueskyPostsForUser() error = %v", err) 231 + } 232 + 233 + if len(posts) != 1 { 234 + t.Errorf("ListBlueskyPostsForUser() returned %d posts, want 1", len(posts)) 235 + } 236 + 237 + if len(posts) > 0 { 238 + if posts[0].Text != "@alice.bsky.social pushed myapp:latest" { 239 + t.Errorf("Post text = %q, want %q", posts[0].Text, "@alice.bsky.social pushed myapp:latest") 240 + } 241 + if posts[0].CreatedAt != "2025-01-01T00:00:00Z" { 242 + t.Errorf("Post createdAt = %q, want %q", posts[0].CreatedAt, "2025-01-01T00:00:00Z") 243 + } 244 + } 245 + 246 + // List posts for bob - should find 0 247 + bobPosts, err := pds.ListBlueskyPostsForUser(ctx, "did:plc:bob456") 248 + if err != nil { 249 + t.Fatalf("ListBlueskyPostsForUser(bob) error = %v", err) 250 + } 251 + 252 + if len(bobPosts) != 0 { 253 + t.Errorf("ListBlueskyPostsForUser(bob) returned %d posts, want 0", len(bobPosts)) 254 + } 255 + 256 + // Delete posts for alice 257 + deleted, err := pds.deleteBlueskyPosts(ctx, aliceDID) 258 + if err != nil { 259 + t.Fatalf("deleteBlueskyPosts() error = %v", err) 260 + } 261 + 262 + if deleted != 1 { 263 + t.Errorf("deleteBlueskyPosts() deleted = %d, want 1", deleted) 264 + } 265 + 266 + // List posts for alice again - should find 0 now 267 + postsAfterDelete, err := pds.ListBlueskyPostsForUser(ctx, aliceDID) 268 + if err != nil { 269 + t.Fatalf("ListBlueskyPostsForUser() after delete error = %v", err) 270 + } 271 + 272 + if len(postsAfterDelete) != 0 { 273 + t.Errorf("ListBlueskyPostsForUser() after delete returned %d posts, want 0", len(postsAfterDelete)) 274 + } 275 + } 276 + 277 + func TestDeleteUserData_IncludesPosts(t *testing.T) { 278 + // Create a test PDS with records index 279 + pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner") 280 + defer cleanup() 281 + 282 + ctx := sharedCtx 283 + 284 + // Create a test post that mentions alice 285 + aliceDID := "did:plc:alice123" 286 + post := &bsky.FeedPost{ 287 + LexiconTypeID: atproto.BskyPostCollection, 288 + Text: "@alice.bsky.social pushed myapp:latest", 289 + Facets: []*bsky.RichtextFacet{{ 290 + Index: &bsky.RichtextFacet_ByteSlice{ 291 + ByteStart: 0, 292 + ByteEnd: 20, 293 + }, 294 + Features: []*bsky.RichtextFacet_Features_Elem{{ 295 + RichtextFacet_Mention: &bsky.RichtextFacet_Mention{ 296 + Did: aliceDID, 297 + }, 298 + }}, 299 + }}, 300 + CreatedAt: "2025-01-01T00:00:00Z", 301 + } 302 + 303 + // Create the post in the PDS 304 + rkey, _, err := pds.repomgr.CreateRecord(ctx, pds.uid, atproto.BskyPostCollection, post) 305 + if err != nil { 306 + t.Fatalf("Failed to create test post: %v", err) 307 + } 308 + 309 + // Index the record 310 + if pds.recordsIndex != nil { 311 + err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID) 312 + if err != nil { 313 + t.Fatalf("Failed to index test post: %v", err) 314 + } 315 + } 316 + 317 + // Call DeleteUserData 318 + result, err := pds.DeleteUserData(ctx, aliceDID) 319 + if err != nil { 320 + t.Fatalf("DeleteUserData() error = %v", err) 321 + } 322 + 323 + // Verify posts were deleted 324 + if result.PostsDeleted != 1 { 325 + t.Errorf("DeleteUserData() PostsDeleted = %d, want 1", result.PostsDeleted) 326 + } 327 + 328 + // Verify post is actually gone 329 + posts, err := pds.ListBlueskyPostsForUser(ctx, aliceDID) 330 + if err != nil { 331 + t.Fatalf("ListBlueskyPostsForUser() error = %v", err) 332 + } 333 + 334 + if len(posts) != 0 { 335 + t.Errorf("Posts still exist after DeleteUserData, count = %d", len(posts)) 336 + } 337 + }
+36 -8
pkg/hold/pds/xrpc.go
··· 1499 1499 1500 1500 // HoldUserDataExport represents the GDPR data export from a hold service 1501 1501 type HoldUserDataExport struct { 1502 - ExportedAt time.Time `json:"exported_at"` 1503 - HoldDID string `json:"hold_did"` 1504 - UserDID string `json:"user_did"` 1505 - IsCaptain bool `json:"is_captain"` 1506 - CrewRecord *CrewExport `json:"crew_record,omitempty"` 1507 - LayerRecords []LayerExport `json:"layer_records"` 1508 - StatsRecords []StatsExport `json:"stats_records"` 1502 + ExportedAt time.Time `json:"exported_at"` 1503 + HoldDID string `json:"hold_did"` 1504 + UserDID string `json:"user_did"` 1505 + IsCaptain bool `json:"is_captain"` 1506 + CrewRecord *CrewExport `json:"crew_record,omitempty"` 1507 + LayerRecords []LayerExport `json:"layer_records"` 1508 + StatsRecords []StatsExport `json:"stats_records"` 1509 + BlueskyPosts []BlueskyPostExport `json:"bluesky_posts"` 1509 1510 } 1510 1511 1511 1512 // CrewExport represents a sanitized crew record for export ··· 1533 1534 LastPull string `json:"last_pull,omitempty"` 1534 1535 LastPush string `json:"last_push,omitempty"` 1535 1536 UpdatedAt string `json:"updated_at"` 1537 + } 1538 + 1539 + // BlueskyPostExport represents a Bluesky post that mentions the user 1540 + type BlueskyPostExport struct { 1541 + URI string `json:"uri"` // at://did/app.bsky.feed.post/rkey 1542 + Text string `json:"text"` // Post content 1543 + CreatedAt string `json:"created_at"` // When the post was created 1536 1544 } 1537 1545 1538 1546 // HandleExportUserData handles GDPR data export requests for a specific user. ··· 1543 1551 // - io.atcr.hold.layer records where userDid matches 1544 1552 // - io.atcr.hold.crew record for the DID (if exists) 1545 1553 // - io.atcr.hold.stats records where ownerDid matches 1554 + // - app.bsky.feed.post records that mention the user 1546 1555 // - Whether the user is the hold captain 1547 1556 // 1548 1557 // Authentication: Requires valid service token from user's PDS ··· 1564 1573 UserDID: user.DID, 1565 1574 LayerRecords: []LayerExport{}, 1566 1575 StatsRecords: []StatsExport{}, 1576 + BlueskyPosts: []BlueskyPostExport{}, 1567 1577 } 1568 1578 1569 1579 // Check if user is captain ··· 1622 1632 } 1623 1633 } 1624 1634 1635 + // Get Bluesky posts that mention this user (GDPR compliance) 1636 + blueskyPosts, err := h.pds.ListBlueskyPostsForUser(r.Context(), user.DID) 1637 + if err != nil { 1638 + slog.Warn("Failed to get bluesky posts for export", 1639 + "user_did", user.DID, 1640 + "error", err) 1641 + // Continue with empty list - don't fail entire export 1642 + } else { 1643 + for _, post := range blueskyPosts { 1644 + export.BlueskyPosts = append(export.BlueskyPosts, BlueskyPostExport{ 1645 + URI: fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), atproto.BskyPostCollection, post.Rkey), 1646 + Text: post.Text, 1647 + CreatedAt: post.CreatedAt, 1648 + }) 1649 + } 1650 + } 1651 + 1625 1652 slog.Info("GDPR data export completed", 1626 1653 "user_did", user.DID, 1627 1654 "hold_did", h.pds.DID(), 1628 1655 "is_captain", export.IsCaptain, 1629 1656 "has_crew_record", export.CrewRecord != nil, 1630 1657 "layer_count", len(export.LayerRecords), 1631 - "stats_count", len(export.StatsRecords)) 1658 + "stats_count", len(export.StatsRecords), 1659 + "post_count", len(export.BlueskyPosts)) 1632 1660 1633 1661 render.JSON(w, r, export) 1634 1662 }