···4343 CrewDeleted bool `json:"crew_deleted,omitempty"`
4444 LayersDeleted int `json:"layers_deleted,omitempty"`
4545 StatsDeleted int `json:"stats_deleted,omitempty"`
4646+ PostsDeleted int `json:"posts_deleted,omitempty"`
4647}
47484849// DeleteAccountHandler handles GDPR account deletion requests
···267268 CrewDeleted bool `json:"crew_deleted"`
268269 LayersDeleted int `json:"layers_deleted"`
269270 StatsDeleted int `json:"stats_deleted"`
271271+ PostsDeleted int `json:"posts_deleted"`
270272 }
271273 if err := json.NewDecoder(resp.Body).Decode(&holdResponse); err != nil {
272274 result.Error = fmt.Sprintf("Failed to parse response: %v", err)
···278280 result.CrewDeleted = holdResponse.CrewDeleted
279281 result.LayersDeleted = holdResponse.LayersDeleted
280282 result.StatsDeleted = holdResponse.StatsDeleted
283283+ result.PostsDeleted = holdResponse.PostsDeleted
281284282285 slog.Debug("Successfully deleted data from hold",
283286 "component", "delete",
···285288 "user_did", user.DID,
286289 "crew_deleted", holdResponse.CrewDeleted,
287290 "layers_deleted", holdResponse.LayersDeleted,
288288- "stats_deleted", holdResponse.StatsDeleted)
291291+ "stats_deleted", holdResponse.StatsDeleted,
292292+ "posts_deleted", holdResponse.PostsDeleted)
289293290294 return result
291295}
+80-8
pkg/appview/templates/pages/privacy.html
···21212222 <h3>Data Stored on Our Infrastructure</h3>
23232424- <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>
2424+ <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>
25252626 <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>
2727···4444 </div>
45454646 <div class="legal-section">
4747+ <h2>Our Services and Their Data</h2>
4848+4949+ <p>AT Container Registry consists of multiple services, each with distinct data responsibilities:</p>
5050+5151+ <h3>AppView (atcr.io)</h3>
5252+ <p>The registry frontend you interact with directly. Stores:</p>
5353+ <ul>
5454+ <li>OAuth sessions and tokens for authentication</li>
5555+ <li>Device tokens for the Docker credential helper</li>
5656+ <li>Web UI sessions</li>
5757+ <li>Cached metadata from your PDS (indexes for search and display)</li>
5858+ </ul>
5959+6060+ <h3>ATCR-Hosted Hold Services</h3>
6161+ <p>Storage backends we operate (e.g., <code>hold01.atcr.io</code>). Each hold has an embedded PDS and stores:</p>
6262+ <ul>
6363+ <li>OCI blobs (container image layers) in object storage</li>
6464+ <li>Layer records in the hold's embedded PDS linking your DID to blob references</li>
6565+ <li>Crew membership records for access control</li>
6666+ </ul>
6767+ <p>Hold services on <code>*.atcr.io</code> domains are operated by us and covered by this policy.</p>
6868+6969+ <h3>User-Deployed Hold Services (BYOS)</h3>
7070+ <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>
7171+ </div>
7272+7373+ <div class="legal-section">
4774 <h2>Data Sharing and Deduplication</h2>
48754976 <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>
···75102 <h3>Right to Erasure ("Right to be Forgotten")</h3>
76103 <p>You may request deletion of your data via the account settings page. Due to our technical architecture, deletion works as follows:</p>
771047878- <p><strong>Immediately deleted:</strong></p>
105105+ <p><strong>Immediately deleted from AppView:</strong></p>
79106 <ul>
8080- <li>Layer records on our PDS that reference your DID</li>
81107 <li>OAuth tokens, web UI sessions, and device tokens</li>
8282- <li>Cached PDS data</li>
108108+ <li>Cached PDS data (manifest and tag indexes)</li>
83109 <li>Server logs containing your identifiers (deleted or anonymized, if retained)</li>
84110 </ul>
851118686- <p><strong>Deleted within 30 days:</strong></p>
112112+ <p><strong>Immediately deleted from ATCR-hosted holds:</strong></p>
113113+ <ul>
114114+ <li>Layer records in the hold's embedded PDS that reference your DID</li>
115115+ <li>Crew membership records</li>
116116+ </ul>
117117+118118+ <p><strong>Deleted within 30 days from ATCR-hosted holds:</strong></p>
87119 <ul>
8888- <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>
120120+ <li>OCI blobs in object storage that are no longer referenced by any user (via garbage collection)</li>
121121+ </ul>
122122+123123+ <p><strong>User-deployed holds:</strong></p>
124124+ <ul>
125125+ <li>We attempt to delete your data via API, but success depends on hold availability</li>
126126+ <li>Data on holds we do not operate is governed by that operator's policies</li>
89127 </ul>
9012891129 <p><strong>Cannot be deleted by us:</strong></p>
···174212 <thead>
175213 <tr>
176214 <th>Data Type</th>
215215+ <th>Service</th>
177216 <th>Retention Period</th>
178217 </tr>
179218 </thead>
180219 <tbody>
181220 <tr>
182221 <td>OAuth tokens</td>
222222+ <td>AppView</td>
183223 <td>Until revoked or logout</td>
184224 </tr>
185225 <tr>
186226 <td>Web UI session tokens</td>
227227+ <td>AppView</td>
187228 <td>Until logout or expiration</td>
188229 </tr>
189230 <tr>
190231 <td>Device tokens (credential helper)</td>
232232+ <td>AppView</td>
191233 <td>Until revoked by user</td>
192234 </tr>
193235 <tr>
194236 <td>Cached PDS data</td>
237237+ <td>AppView</td>
195238 <td>Refreshed periodically; deleted on account deletion</td>
196239 </tr>
197240 <tr>
198241 <td>Server logs</td>
242242+ <td>AppView</td>
199243 <td>Currently ephemeral; this policy will be updated if log retention is implemented</td>
200244 </tr>
201245 <tr>
202202- <td>Layer records (our PDS)</td>
246246+ <td>Layer records</td>
247247+ <td>Hold PDS</td>
203248 <td>Until you request deletion</td>
204249 </tr>
205250 <tr>
206251 <td>OCI blobs</td>
207207- <td>Until no longer referenced (pruned monthly)</td>
252252+ <td>Hold Storage</td>
253253+ <td>Until no longer referenced (pruned within 30 days)</td>
208254 </tr>
209255 </tbody>
210256 </table>
···221267 <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>
222268 <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>
223269 </ol>
270270+ </div>
271271+272272+ <div class="legal-section">
273273+ <h2>Bring Your Own Storage (BYOS)</h2>
274274+275275+ <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>
276276+277277+ <h3>ATCR-Hosted Holds</h3>
278278+ <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>
279279+280280+ <h3>User-Deployed Holds</h3>
281281+ <p>If you use a hold service not operated by us:</p>
282282+ <ul>
283283+ <li>That hold's data practices are governed by its operator's privacy policy, not ours</li>
284284+ <li>When you request account deletion, we attempt to delete your data from all holds via API</li>
285285+ <li>We cannot guarantee deletion for holds that are offline or refuse the request</li>
286286+ <li>You should contact that hold's operator directly for data requests we cannot fulfill</li>
287287+ </ul>
288288+289289+ <h3>If You Operate a Hold</h3>
290290+ <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>
291291+ <ul>
292292+ <li>Responding to deletion requests from users of your hold</li>
293293+ <li>Implementing appropriate data retention policies</li>
294294+ <li>Publishing your own privacy policy if required by law</li>
295295+ </ul>
224296 </div>
225297226298 <div class="legal-section">
+184-1
pkg/hold/pds/delete.go
···11package pds
2233import (
44+ "bytes"
45 "context"
56 "fmt"
67 "log/slog"
7889 "atcr.io/pkg/atproto"
1010+ bsky "github.com/bluesky-social/indigo/api/bsky"
911)
10121113// UserDeleteResult contains the results of deleting a user's data from the hold
···1315 CrewDeleted bool `json:"crew_deleted"`
1416 LayersDeleted int `json:"layers_deleted"`
1517 StatsDeleted int `json:"stats_deleted"`
1818+ PostsDeleted int `json:"posts_deleted"`
1619}
17201821// DeleteUserData deletes all data for a user from the hold's PDS.
···2023// - Crew record (if user is a crew member)
2124// - Layer records (where userDid matches)
2225// - Stats records (where ownerDid matches)
2626+// - Bluesky posts that mention the user (for GDPR compliance)
2327//
2428// NOTE: This does NOT delete the captain record if the user is the hold owner.
2529// NOTE: This does NOT delete actual blob data from S3 - only the PDS records.
···6064 }
6165 result.StatsDeleted = statsDeleted
62666767+ // 4. Delete Bluesky posts that mention this user (GDPR compliance)
6868+ postsDeleted, err := p.deleteBlueskyPosts(ctx, userDID)
6969+ if err != nil {
7070+ slog.Warn("Failed to delete bluesky posts",
7171+ "user_did", userDID,
7272+ "error", err)
7373+ // Continue - this is best-effort
7474+ }
7575+ result.PostsDeleted = postsDeleted
7676+6377 slog.Info("User data deletion complete",
6478 "user_did", userDID,
6579 "hold_did", p.DID(),
6680 "crew_deleted", result.CrewDeleted,
6781 "layers_deleted", result.LayersDeleted,
6868- "stats_deleted", result.StatsDeleted)
8282+ "stats_deleted", result.StatsDeleted,
8383+ "posts_deleted", result.PostsDeleted)
69847085 return result, nil
7186}
···190205191206 return deleted, nil
192207}
208208+209209+// deleteBlueskyPosts removes all Bluesky posts that mention a user's DID
210210+// Posts store mentions in facets: Facets[].Features[].RichtextFacet_Mention.Did
211211+func (p *HoldPDS) deleteBlueskyPosts(ctx context.Context, userDID string) (int, error) {
212212+ if p.recordsIndex == nil {
213213+ return 0, fmt.Errorf("records index not available")
214214+ }
215215+216216+ deleted := 0
217217+ cursor := ""
218218+ batchSize := 100
219219+220220+ for {
221221+ // Get all Bluesky posts
222222+ records, nextCursor, err := p.recordsIndex.ListRecords(atproto.BskyPostCollection, batchSize, cursor, false)
223223+ if err != nil {
224224+ return deleted, fmt.Errorf("failed to list bluesky posts: %w", err)
225225+ }
226226+227227+ for _, rec := range records {
228228+ // Get the record bytes to check the facets
229229+ recordPath := rec.Collection + "/" + rec.Rkey
230230+ _, recBytes, err := p.GetRecordBytes(ctx, recordPath)
231231+ if err != nil {
232232+ slog.Warn("Failed to get post record bytes",
233233+ "rkey", rec.Rkey,
234234+ "error", err)
235235+ continue
236236+ }
237237+238238+ if recBytes == nil {
239239+ continue
240240+ }
241241+242242+ // Parse as FeedPost to check facets
243243+ var post bsky.FeedPost
244244+ if err := post.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
245245+ slog.Warn("Failed to unmarshal post record",
246246+ "rkey", rec.Rkey,
247247+ "error", err)
248248+ continue
249249+ }
250250+251251+ // Check if any facet mentions this user's DID
252252+ if !postMentionsUser(&post, userDID) {
253253+ continue
254254+ }
255255+256256+ // Delete from repo (MST)
257257+ err = p.repomgr.DeleteRecord(ctx, p.uid, atproto.BskyPostCollection, rec.Rkey)
258258+ if err != nil {
259259+ slog.Warn("Failed to delete bluesky post from repo",
260260+ "rkey", rec.Rkey,
261261+ "error", err)
262262+ continue
263263+ }
264264+265265+ // Delete from index
266266+ err = p.recordsIndex.DeleteRecord(atproto.BskyPostCollection, rec.Rkey)
267267+ if err != nil {
268268+ slog.Warn("Failed to delete bluesky post from index",
269269+ "rkey", rec.Rkey,
270270+ "error", err)
271271+ }
272272+273273+ deleted++
274274+ }
275275+276276+ if nextCursor == "" {
277277+ break
278278+ }
279279+ cursor = nextCursor
280280+ }
281281+282282+ if deleted > 0 {
283283+ slog.Debug("Deleted bluesky posts mentioning user", "user_did", userDID, "count", deleted)
284284+ }
285285+286286+ return deleted, nil
287287+}
288288+289289+// postMentionsUser checks if a post's facets contain a mention of the given DID
290290+func postMentionsUser(post *bsky.FeedPost, userDID string) bool {
291291+ if post.Facets == nil {
292292+ return false
293293+ }
294294+295295+ for _, facet := range post.Facets {
296296+ if facet.Features == nil {
297297+ continue
298298+ }
299299+ for _, feature := range facet.Features {
300300+ if feature.RichtextFacet_Mention != nil && feature.RichtextFacet_Mention.Did == userDID {
301301+ return true
302302+ }
303303+ }
304304+ }
305305+ return false
306306+}
307307+308308+// BlueskyPostInfo represents a Bluesky post for export
309309+type BlueskyPostInfo struct {
310310+ Rkey string
311311+ Text string
312312+ CreatedAt string
313313+}
314314+315315+// ListBlueskyPostsForUser returns all Bluesky posts that mention a user's DID
316316+func (p *HoldPDS) ListBlueskyPostsForUser(ctx context.Context, userDID string) ([]BlueskyPostInfo, error) {
317317+ if p.recordsIndex == nil {
318318+ return nil, fmt.Errorf("records index not available")
319319+ }
320320+321321+ var posts []BlueskyPostInfo
322322+ cursor := ""
323323+ batchSize := 100
324324+325325+ for {
326326+ // Get all Bluesky posts
327327+ records, nextCursor, err := p.recordsIndex.ListRecords(atproto.BskyPostCollection, batchSize, cursor, false)
328328+ if err != nil {
329329+ return posts, fmt.Errorf("failed to list bluesky posts: %w", err)
330330+ }
331331+332332+ for _, rec := range records {
333333+ // Get the record bytes to check the facets
334334+ recordPath := rec.Collection + "/" + rec.Rkey
335335+ _, recBytes, err := p.GetRecordBytes(ctx, recordPath)
336336+ if err != nil {
337337+ slog.Warn("Failed to get post record bytes for export",
338338+ "rkey", rec.Rkey,
339339+ "error", err)
340340+ continue
341341+ }
342342+343343+ if recBytes == nil {
344344+ continue
345345+ }
346346+347347+ // Parse as FeedPost to check facets
348348+ var post bsky.FeedPost
349349+ if err := post.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
350350+ slog.Warn("Failed to unmarshal post record for export",
351351+ "rkey", rec.Rkey,
352352+ "error", err)
353353+ continue
354354+ }
355355+356356+ // Check if any facet mentions this user's DID
357357+ if !postMentionsUser(&post, userDID) {
358358+ continue
359359+ }
360360+361361+ posts = append(posts, BlueskyPostInfo{
362362+ Rkey: rec.Rkey,
363363+ Text: post.Text,
364364+ CreatedAt: post.CreatedAt,
365365+ })
366366+ }
367367+368368+ if nextCursor == "" {
369369+ break
370370+ }
371371+ cursor = nextCursor
372372+ }
373373+374374+ return posts, nil
375375+}
+337
pkg/hold/pds/delete_test.go
···11+package pds
22+33+import (
44+ "testing"
55+66+ "atcr.io/pkg/atproto"
77+ bsky "github.com/bluesky-social/indigo/api/bsky"
88+)
99+1010+func TestPostMentionsUser(t *testing.T) {
1111+ tests := []struct {
1212+ name string
1313+ post *bsky.FeedPost
1414+ userDID string
1515+ expected bool
1616+ }{
1717+ {
1818+ name: "post mentions user",
1919+ post: &bsky.FeedPost{
2020+ Text: "@alice.bsky.social pushed myapp:latest",
2121+ Facets: []*bsky.RichtextFacet{{
2222+ Index: &bsky.RichtextFacet_ByteSlice{
2323+ ByteStart: 0,
2424+ ByteEnd: 20,
2525+ },
2626+ Features: []*bsky.RichtextFacet_Features_Elem{{
2727+ RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
2828+ Did: "did:plc:alice123",
2929+ },
3030+ }},
3131+ }},
3232+ },
3333+ userDID: "did:plc:alice123",
3434+ expected: true,
3535+ },
3636+ {
3737+ name: "post mentions different user",
3838+ post: &bsky.FeedPost{
3939+ Text: "@bob.bsky.social pushed myapp:latest",
4040+ Facets: []*bsky.RichtextFacet{{
4141+ Index: &bsky.RichtextFacet_ByteSlice{
4242+ ByteStart: 0,
4343+ ByteEnd: 18,
4444+ },
4545+ Features: []*bsky.RichtextFacet_Features_Elem{{
4646+ RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
4747+ Did: "did:plc:bob456",
4848+ },
4949+ }},
5050+ }},
5151+ },
5252+ userDID: "did:plc:alice123",
5353+ expected: false,
5454+ },
5555+ {
5656+ name: "post with no facets",
5757+ post: &bsky.FeedPost{
5858+ Text: "Just a regular post",
5959+ Facets: nil,
6060+ },
6161+ userDID: "did:plc:alice123",
6262+ expected: false,
6363+ },
6464+ {
6565+ name: "post with empty facets",
6666+ post: &bsky.FeedPost{
6767+ Text: "Just a regular post",
6868+ Facets: []*bsky.RichtextFacet{},
6969+ },
7070+ userDID: "did:plc:alice123",
7171+ expected: false,
7272+ },
7373+ {
7474+ name: "post with link facet only (no mention)",
7575+ post: &bsky.FeedPost{
7676+ Text: "Check out https://example.com",
7777+ Facets: []*bsky.RichtextFacet{{
7878+ Index: &bsky.RichtextFacet_ByteSlice{
7979+ ByteStart: 10,
8080+ ByteEnd: 30,
8181+ },
8282+ Features: []*bsky.RichtextFacet_Features_Elem{{
8383+ RichtextFacet_Link: &bsky.RichtextFacet_Link{
8484+ Uri: "https://example.com",
8585+ },
8686+ }},
8787+ }},
8888+ },
8989+ userDID: "did:plc:alice123",
9090+ expected: false,
9191+ },
9292+ {
9393+ name: "post with multiple mentions - match second",
9494+ post: &bsky.FeedPost{
9595+ Text: "@bob.bsky.social and @alice.bsky.social pushed",
9696+ Facets: []*bsky.RichtextFacet{
9797+ {
9898+ Index: &bsky.RichtextFacet_ByteSlice{
9999+ ByteStart: 0,
100100+ ByteEnd: 18,
101101+ },
102102+ Features: []*bsky.RichtextFacet_Features_Elem{{
103103+ RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
104104+ Did: "did:plc:bob456",
105105+ },
106106+ }},
107107+ },
108108+ {
109109+ Index: &bsky.RichtextFacet_ByteSlice{
110110+ ByteStart: 23,
111111+ ByteEnd: 43,
112112+ },
113113+ Features: []*bsky.RichtextFacet_Features_Elem{{
114114+ RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
115115+ Did: "did:plc:alice123",
116116+ },
117117+ }},
118118+ },
119119+ },
120120+ },
121121+ userDID: "did:plc:alice123",
122122+ expected: true,
123123+ },
124124+ {
125125+ name: "facet with nil features",
126126+ post: &bsky.FeedPost{
127127+ Text: "Some post",
128128+ Facets: []*bsky.RichtextFacet{{
129129+ Index: &bsky.RichtextFacet_ByteSlice{
130130+ ByteStart: 0,
131131+ ByteEnd: 4,
132132+ },
133133+ Features: nil,
134134+ }},
135135+ },
136136+ userDID: "did:plc:alice123",
137137+ expected: false,
138138+ },
139139+ }
140140+141141+ for _, tt := range tests {
142142+ t.Run(tt.name, func(t *testing.T) {
143143+ result := postMentionsUser(tt.post, tt.userDID)
144144+ if result != tt.expected {
145145+ t.Errorf("postMentionsUser() = %v, want %v", result, tt.expected)
146146+ }
147147+ })
148148+ }
149149+}
150150+151151+func TestDeleteBlueskyPosts_NoPosts(t *testing.T) {
152152+ // Create a test PDS with records index
153153+ pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner")
154154+ defer cleanup()
155155+156156+ ctx := sharedCtx
157157+158158+ // Delete posts for a user that has no posts
159159+ deleted, err := pds.deleteBlueskyPosts(ctx, "did:plc:nonexistent")
160160+ if err != nil {
161161+ t.Fatalf("deleteBlueskyPosts() error = %v", err)
162162+ }
163163+164164+ if deleted != 0 {
165165+ t.Errorf("deleteBlueskyPosts() deleted = %d, want 0", deleted)
166166+ }
167167+}
168168+169169+func TestListBlueskyPostsForUser_NoPosts(t *testing.T) {
170170+ // Create a test PDS with records index
171171+ pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner")
172172+ defer cleanup()
173173+174174+ ctx := sharedCtx
175175+176176+ // List posts for a user that has no posts
177177+ posts, err := pds.ListBlueskyPostsForUser(ctx, "did:plc:nonexistent")
178178+ if err != nil {
179179+ t.Fatalf("ListBlueskyPostsForUser() error = %v", err)
180180+ }
181181+182182+ if len(posts) != 0 {
183183+ t.Errorf("ListBlueskyPostsForUser() returned %d posts, want 0", len(posts))
184184+ }
185185+}
186186+187187+func TestDeleteAndListBlueskyPosts_WithPosts(t *testing.T) {
188188+ // Create a test PDS with records index
189189+ pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner")
190190+ defer cleanup()
191191+192192+ ctx := sharedCtx
193193+194194+ // Create a test post that mentions alice
195195+ aliceDID := "did:plc:alice123"
196196+ post := &bsky.FeedPost{
197197+ LexiconTypeID: atproto.BskyPostCollection,
198198+ Text: "@alice.bsky.social pushed myapp:latest",
199199+ Facets: []*bsky.RichtextFacet{{
200200+ Index: &bsky.RichtextFacet_ByteSlice{
201201+ ByteStart: 0,
202202+ ByteEnd: 20,
203203+ },
204204+ Features: []*bsky.RichtextFacet_Features_Elem{{
205205+ RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
206206+ Did: aliceDID,
207207+ },
208208+ }},
209209+ }},
210210+ CreatedAt: "2025-01-01T00:00:00Z",
211211+ }
212212+213213+ // Create the post in the PDS
214214+ rkey, _, err := pds.repomgr.CreateRecord(ctx, pds.uid, atproto.BskyPostCollection, post)
215215+ if err != nil {
216216+ t.Fatalf("Failed to create test post: %v", err)
217217+ }
218218+219219+ // Index the record
220220+ if pds.recordsIndex != nil {
221221+ err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID)
222222+ if err != nil {
223223+ t.Fatalf("Failed to index test post: %v", err)
224224+ }
225225+ }
226226+227227+ // List posts for alice - should find 1
228228+ posts, err := pds.ListBlueskyPostsForUser(ctx, aliceDID)
229229+ if err != nil {
230230+ t.Fatalf("ListBlueskyPostsForUser() error = %v", err)
231231+ }
232232+233233+ if len(posts) != 1 {
234234+ t.Errorf("ListBlueskyPostsForUser() returned %d posts, want 1", len(posts))
235235+ }
236236+237237+ if len(posts) > 0 {
238238+ if posts[0].Text != "@alice.bsky.social pushed myapp:latest" {
239239+ t.Errorf("Post text = %q, want %q", posts[0].Text, "@alice.bsky.social pushed myapp:latest")
240240+ }
241241+ if posts[0].CreatedAt != "2025-01-01T00:00:00Z" {
242242+ t.Errorf("Post createdAt = %q, want %q", posts[0].CreatedAt, "2025-01-01T00:00:00Z")
243243+ }
244244+ }
245245+246246+ // List posts for bob - should find 0
247247+ bobPosts, err := pds.ListBlueskyPostsForUser(ctx, "did:plc:bob456")
248248+ if err != nil {
249249+ t.Fatalf("ListBlueskyPostsForUser(bob) error = %v", err)
250250+ }
251251+252252+ if len(bobPosts) != 0 {
253253+ t.Errorf("ListBlueskyPostsForUser(bob) returned %d posts, want 0", len(bobPosts))
254254+ }
255255+256256+ // Delete posts for alice
257257+ deleted, err := pds.deleteBlueskyPosts(ctx, aliceDID)
258258+ if err != nil {
259259+ t.Fatalf("deleteBlueskyPosts() error = %v", err)
260260+ }
261261+262262+ if deleted != 1 {
263263+ t.Errorf("deleteBlueskyPosts() deleted = %d, want 1", deleted)
264264+ }
265265+266266+ // List posts for alice again - should find 0 now
267267+ postsAfterDelete, err := pds.ListBlueskyPostsForUser(ctx, aliceDID)
268268+ if err != nil {
269269+ t.Fatalf("ListBlueskyPostsForUser() after delete error = %v", err)
270270+ }
271271+272272+ if len(postsAfterDelete) != 0 {
273273+ t.Errorf("ListBlueskyPostsForUser() after delete returned %d posts, want 0", len(postsAfterDelete))
274274+ }
275275+}
276276+277277+func TestDeleteUserData_IncludesPosts(t *testing.T) {
278278+ // Create a test PDS with records index
279279+ pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner")
280280+ defer cleanup()
281281+282282+ ctx := sharedCtx
283283+284284+ // Create a test post that mentions alice
285285+ aliceDID := "did:plc:alice123"
286286+ post := &bsky.FeedPost{
287287+ LexiconTypeID: atproto.BskyPostCollection,
288288+ Text: "@alice.bsky.social pushed myapp:latest",
289289+ Facets: []*bsky.RichtextFacet{{
290290+ Index: &bsky.RichtextFacet_ByteSlice{
291291+ ByteStart: 0,
292292+ ByteEnd: 20,
293293+ },
294294+ Features: []*bsky.RichtextFacet_Features_Elem{{
295295+ RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
296296+ Did: aliceDID,
297297+ },
298298+ }},
299299+ }},
300300+ CreatedAt: "2025-01-01T00:00:00Z",
301301+ }
302302+303303+ // Create the post in the PDS
304304+ rkey, _, err := pds.repomgr.CreateRecord(ctx, pds.uid, atproto.BskyPostCollection, post)
305305+ if err != nil {
306306+ t.Fatalf("Failed to create test post: %v", err)
307307+ }
308308+309309+ // Index the record
310310+ if pds.recordsIndex != nil {
311311+ err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID)
312312+ if err != nil {
313313+ t.Fatalf("Failed to index test post: %v", err)
314314+ }
315315+ }
316316+317317+ // Call DeleteUserData
318318+ result, err := pds.DeleteUserData(ctx, aliceDID)
319319+ if err != nil {
320320+ t.Fatalf("DeleteUserData() error = %v", err)
321321+ }
322322+323323+ // Verify posts were deleted
324324+ if result.PostsDeleted != 1 {
325325+ t.Errorf("DeleteUserData() PostsDeleted = %d, want 1", result.PostsDeleted)
326326+ }
327327+328328+ // Verify post is actually gone
329329+ posts, err := pds.ListBlueskyPostsForUser(ctx, aliceDID)
330330+ if err != nil {
331331+ t.Fatalf("ListBlueskyPostsForUser() error = %v", err)
332332+ }
333333+334334+ if len(posts) != 0 {
335335+ t.Errorf("Posts still exist after DeleteUserData, count = %d", len(posts))
336336+ }
337337+}
+36-8
pkg/hold/pds/xrpc.go
···1499149915001500// HoldUserDataExport represents the GDPR data export from a hold service
15011501type HoldUserDataExport struct {
15021502- ExportedAt time.Time `json:"exported_at"`
15031503- HoldDID string `json:"hold_did"`
15041504- UserDID string `json:"user_did"`
15051505- IsCaptain bool `json:"is_captain"`
15061506- CrewRecord *CrewExport `json:"crew_record,omitempty"`
15071507- LayerRecords []LayerExport `json:"layer_records"`
15081508- StatsRecords []StatsExport `json:"stats_records"`
15021502+ ExportedAt time.Time `json:"exported_at"`
15031503+ HoldDID string `json:"hold_did"`
15041504+ UserDID string `json:"user_did"`
15051505+ IsCaptain bool `json:"is_captain"`
15061506+ CrewRecord *CrewExport `json:"crew_record,omitempty"`
15071507+ LayerRecords []LayerExport `json:"layer_records"`
15081508+ StatsRecords []StatsExport `json:"stats_records"`
15091509+ BlueskyPosts []BlueskyPostExport `json:"bluesky_posts"`
15091510}
1510151115111512// CrewExport represents a sanitized crew record for export
···15331534 LastPull string `json:"last_pull,omitempty"`
15341535 LastPush string `json:"last_push,omitempty"`
15351536 UpdatedAt string `json:"updated_at"`
15371537+}
15381538+15391539+// BlueskyPostExport represents a Bluesky post that mentions the user
15401540+type BlueskyPostExport struct {
15411541+ URI string `json:"uri"` // at://did/app.bsky.feed.post/rkey
15421542+ Text string `json:"text"` // Post content
15431543+ CreatedAt string `json:"created_at"` // When the post was created
15361544}
1537154515381546// HandleExportUserData handles GDPR data export requests for a specific user.
···15431551// - io.atcr.hold.layer records where userDid matches
15441552// - io.atcr.hold.crew record for the DID (if exists)
15451553// - io.atcr.hold.stats records where ownerDid matches
15541554+// - app.bsky.feed.post records that mention the user
15461555// - Whether the user is the hold captain
15471556//
15481557// Authentication: Requires valid service token from user's PDS
···15641573 UserDID: user.DID,
15651574 LayerRecords: []LayerExport{},
15661575 StatsRecords: []StatsExport{},
15761576+ BlueskyPosts: []BlueskyPostExport{},
15671577 }
1568157815691579 // Check if user is captain
···16221632 }
16231633 }
1624163416351635+ // Get Bluesky posts that mention this user (GDPR compliance)
16361636+ blueskyPosts, err := h.pds.ListBlueskyPostsForUser(r.Context(), user.DID)
16371637+ if err != nil {
16381638+ slog.Warn("Failed to get bluesky posts for export",
16391639+ "user_did", user.DID,
16401640+ "error", err)
16411641+ // Continue with empty list - don't fail entire export
16421642+ } else {
16431643+ for _, post := range blueskyPosts {
16441644+ export.BlueskyPosts = append(export.BlueskyPosts, BlueskyPostExport{
16451645+ URI: fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), atproto.BskyPostCollection, post.Rkey),
16461646+ Text: post.Text,
16471647+ CreatedAt: post.CreatedAt,
16481648+ })
16491649+ }
16501650+ }
16511651+16251652 slog.Info("GDPR data export completed",
16261653 "user_did", user.DID,
16271654 "hold_did", h.pds.DID(),
16281655 "is_captain", export.IsCaptain,
16291656 "has_crew_record", export.CrewRecord != nil,
16301657 "layer_count", len(export.LayerRecords),
16311631- "stats_count", len(export.StatsRecords))
16581658+ "stats_count", len(export.StatsRecords),
16591659+ "post_count", len(export.BlueskyPosts))
1632166016331661 render.JSON(w, r, export)
16341662}