diff --git a/pkg/appview/handlers/delete.go b/pkg/appview/handlers/delete.go
index 7edf359..676cafa 100644
--- a/pkg/appview/handlers/delete.go
+++ b/pkg/appview/handlers/delete.go
@@ -43,6 +43,7 @@ type HoldDeleteResult struct {
CrewDeleted bool `json:"crew_deleted,omitempty"`
LayersDeleted int `json:"layers_deleted,omitempty"`
StatsDeleted int `json:"stats_deleted,omitempty"`
+ PostsDeleted int `json:"posts_deleted,omitempty"`
}
// DeleteAccountHandler handles GDPR account deletion requests
@@ -267,6 +268,7 @@ func (h *DeleteAccountHandler) deleteFromSingleHold(ctx context.Context, user *d
CrewDeleted bool `json:"crew_deleted"`
LayersDeleted int `json:"layers_deleted"`
StatsDeleted int `json:"stats_deleted"`
+ PostsDeleted int `json:"posts_deleted"`
}
if err := json.NewDecoder(resp.Body).Decode(&holdResponse); err != nil {
result.Error = fmt.Sprintf("Failed to parse response: %v", err)
@@ -278,6 +280,7 @@ func (h *DeleteAccountHandler) deleteFromSingleHold(ctx context.Context, user *d
result.CrewDeleted = holdResponse.CrewDeleted
result.LayersDeleted = holdResponse.LayersDeleted
result.StatsDeleted = holdResponse.StatsDeleted
+ result.PostsDeleted = holdResponse.PostsDeleted
slog.Debug("Successfully deleted data from hold",
"component", "delete",
@@ -285,7 +288,8 @@ func (h *DeleteAccountHandler) deleteFromSingleHold(ctx context.Context, user *d
"user_did", user.DID,
"crew_deleted", holdResponse.CrewDeleted,
"layers_deleted", holdResponse.LayersDeleted,
- "stats_deleted", holdResponse.StatsDeleted)
+ "stats_deleted", holdResponse.StatsDeleted,
+ "posts_deleted", holdResponse.PostsDeleted)
return result
}
diff --git a/pkg/appview/templates/pages/privacy.html b/pkg/appview/templates/pages/privacy.html
index 12dfcd9..f978f49 100644
--- a/pkg/appview/templates/pages/privacy.html
+++ b/pkg/appview/templates/pages/privacy.html
@@ -21,7 +21,7 @@
Data Stored on Our Infrastructure
- Layer Records: 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.
+ Layer Records: Our hold services (e.g., hold01.atcr.io) 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.
OCI Blobs: 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.
@@ -43,6 +43,33 @@
Server Logs: Our logs may include your handle, DID, IP address, timestamps, and actions performed. Logs are currently ephemeral but may be retained in the future for security and debugging purposes.
+
+
Our Services and Their Data
+
+
AT Container Registry consists of multiple services, each with distinct data responsibilities:
+
+
AppView (atcr.io)
+
The registry frontend you interact with directly. Stores:
+
+ - OAuth sessions and tokens for authentication
+ - Device tokens for the Docker credential helper
+ - Web UI sessions
+ - Cached metadata from your PDS (indexes for search and display)
+
+
+
ATCR-Hosted Hold Services
+
Storage backends we operate (e.g., hold01.atcr.io). Each hold has an embedded PDS and stores:
+
+ - OCI blobs (container image layers) in object storage
+ - Layer records in the hold's embedded PDS linking your DID to blob references
+ - Crew membership records for access control
+
+
Hold services on *.atcr.io domains are operated by us and covered by this policy.
+
+
User-Deployed Hold Services (BYOS)
+
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.
+
+
Data Sharing and Deduplication
@@ -75,17 +102,28 @@
Right to Erasure ("Right to be Forgotten")
You may request deletion of your data via the account settings page. Due to our technical architecture, deletion works as follows:
-
Immediately deleted:
+
Immediately deleted from AppView:
- - Layer records on our PDS that reference your DID
- OAuth tokens, web UI sessions, and device tokens
- - Cached PDS data
+ - Cached PDS data (manifest and tag indexes)
- Server logs containing your identifiers (deleted or anonymized, if retained)
-
Deleted within 30 days:
+
Immediately deleted from ATCR-hosted holds:
- - 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)
+ - Layer records in the hold's embedded PDS that reference your DID
+ - Crew membership records
+
+
+
Deleted within 30 days from ATCR-hosted holds:
+
+ - OCI blobs in object storage that are no longer referenced by any user (via garbage collection)
+
+
+
User-deployed holds:
+
+ - We attempt to delete your data via API, but success depends on hold availability
+ - Data on holds we do not operate is governed by that operator's policies
Cannot be deleted by us:
@@ -174,37 +212,45 @@
| Data Type |
+ Service |
Retention Period |
| OAuth tokens |
+ AppView |
Until revoked or logout |
| Web UI session tokens |
+ AppView |
Until logout or expiration |
| Device tokens (credential helper) |
+ AppView |
Until revoked by user |
| Cached PDS data |
+ AppView |
Refreshed periodically; deleted on account deletion |
| Server logs |
+ AppView |
Currently ephemeral; this policy will be updated if log retention is implemented |
- | Layer records (our PDS) |
+ Layer records |
+ Hold PDS |
Until you request deletion |
| OCI blobs |
- Until no longer referenced (pruned monthly) |
+ Hold Storage |
+ Until no longer referenced (pruned within 30 days) |
@@ -223,6 +269,32 @@
+
+
Bring Your Own Storage (BYOS)
+
+
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.
+
+
ATCR-Hosted Holds
+
Hold services on *.atcr.io domains (e.g., hold01.atcr.io) are operated by us and fully covered by this privacy policy. We can fulfill all data access, export, and deletion requests for these services.
+
+
User-Deployed Holds
+
If you use a hold service not operated by us:
+
+ - That hold's data practices are governed by its operator's privacy policy, not ours
+ - When you request account deletion, we attempt to delete your data from all holds via API
+ - We cannot guarantee deletion for holds that are offline or refuse the request
+ - You should contact that hold's operator directly for data requests we cannot fulfill
+
+
+
If You Operate a Hold
+
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:
+
+ - Responding to deletion requests from users of your hold
+ - Implementing appropriate data retention policies
+ - Publishing your own privacy policy if required by law
+
+
+
How to Exercise Your Rights
diff --git a/pkg/hold/pds/delete.go b/pkg/hold/pds/delete.go
index 48809c5..fa07e03 100644
--- a/pkg/hold/pds/delete.go
+++ b/pkg/hold/pds/delete.go
@@ -1,11 +1,13 @@
package pds
import (
+ "bytes"
"context"
"fmt"
"log/slog"
"atcr.io/pkg/atproto"
+ bsky "github.com/bluesky-social/indigo/api/bsky"
)
// UserDeleteResult contains the results of deleting a user's data from the hold
@@ -13,6 +15,7 @@ type UserDeleteResult struct {
CrewDeleted bool `json:"crew_deleted"`
LayersDeleted int `json:"layers_deleted"`
StatsDeleted int `json:"stats_deleted"`
+ PostsDeleted int `json:"posts_deleted"`
}
// DeleteUserData deletes all data for a user from the hold's PDS.
@@ -20,6 +23,7 @@ type UserDeleteResult struct {
// - Crew record (if user is a crew member)
// - Layer records (where userDid matches)
// - Stats records (where ownerDid matches)
+// - Bluesky posts that mention the user (for GDPR compliance)
//
// NOTE: This does NOT delete the captain record if the user is the hold owner.
// NOTE: This does NOT delete actual blob data from S3 - only the PDS records.
@@ -60,12 +64,23 @@ func (p *HoldPDS) DeleteUserData(ctx context.Context, userDID string) (*UserDele
}
result.StatsDeleted = statsDeleted
+ // 4. Delete Bluesky posts that mention this user (GDPR compliance)
+ postsDeleted, err := p.deleteBlueskyPosts(ctx, userDID)
+ if err != nil {
+ slog.Warn("Failed to delete bluesky posts",
+ "user_did", userDID,
+ "error", err)
+ // Continue - this is best-effort
+ }
+ result.PostsDeleted = postsDeleted
+
slog.Info("User data deletion complete",
"user_did", userDID,
"hold_did", p.DID(),
"crew_deleted", result.CrewDeleted,
"layers_deleted", result.LayersDeleted,
- "stats_deleted", result.StatsDeleted)
+ "stats_deleted", result.StatsDeleted,
+ "posts_deleted", result.PostsDeleted)
return result, nil
}
@@ -190,3 +205,171 @@ func (p *HoldPDS) deleteStatsRecords(ctx context.Context, userDID string) (int,
return deleted, nil
}
+
+// deleteBlueskyPosts removes all Bluesky posts that mention a user's DID
+// Posts store mentions in facets: Facets[].Features[].RichtextFacet_Mention.Did
+func (p *HoldPDS) deleteBlueskyPosts(ctx context.Context, userDID string) (int, error) {
+ if p.recordsIndex == nil {
+ return 0, fmt.Errorf("records index not available")
+ }
+
+ deleted := 0
+ cursor := ""
+ batchSize := 100
+
+ for {
+ // Get all Bluesky posts
+ records, nextCursor, err := p.recordsIndex.ListRecords(atproto.BskyPostCollection, batchSize, cursor, false)
+ if err != nil {
+ return deleted, fmt.Errorf("failed to list bluesky posts: %w", err)
+ }
+
+ for _, rec := range records {
+ // Get the record bytes to check the facets
+ recordPath := rec.Collection + "/" + rec.Rkey
+ _, recBytes, err := p.GetRecordBytes(ctx, recordPath)
+ if err != nil {
+ slog.Warn("Failed to get post record bytes",
+ "rkey", rec.Rkey,
+ "error", err)
+ continue
+ }
+
+ if recBytes == nil {
+ continue
+ }
+
+ // Parse as FeedPost to check facets
+ var post bsky.FeedPost
+ if err := post.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
+ slog.Warn("Failed to unmarshal post record",
+ "rkey", rec.Rkey,
+ "error", err)
+ continue
+ }
+
+ // Check if any facet mentions this user's DID
+ if !postMentionsUser(&post, userDID) {
+ continue
+ }
+
+ // Delete from repo (MST)
+ err = p.repomgr.DeleteRecord(ctx, p.uid, atproto.BskyPostCollection, rec.Rkey)
+ if err != nil {
+ slog.Warn("Failed to delete bluesky post from repo",
+ "rkey", rec.Rkey,
+ "error", err)
+ continue
+ }
+
+ // Delete from index
+ err = p.recordsIndex.DeleteRecord(atproto.BskyPostCollection, rec.Rkey)
+ if err != nil {
+ slog.Warn("Failed to delete bluesky post from index",
+ "rkey", rec.Rkey,
+ "error", err)
+ }
+
+ deleted++
+ }
+
+ if nextCursor == "" {
+ break
+ }
+ cursor = nextCursor
+ }
+
+ if deleted > 0 {
+ slog.Debug("Deleted bluesky posts mentioning user", "user_did", userDID, "count", deleted)
+ }
+
+ return deleted, nil
+}
+
+// postMentionsUser checks if a post's facets contain a mention of the given DID
+func postMentionsUser(post *bsky.FeedPost, userDID string) bool {
+ if post.Facets == nil {
+ return false
+ }
+
+ for _, facet := range post.Facets {
+ if facet.Features == nil {
+ continue
+ }
+ for _, feature := range facet.Features {
+ if feature.RichtextFacet_Mention != nil && feature.RichtextFacet_Mention.Did == userDID {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// BlueskyPostInfo represents a Bluesky post for export
+type BlueskyPostInfo struct {
+ Rkey string
+ Text string
+ CreatedAt string
+}
+
+// ListBlueskyPostsForUser returns all Bluesky posts that mention a user's DID
+func (p *HoldPDS) ListBlueskyPostsForUser(ctx context.Context, userDID string) ([]BlueskyPostInfo, error) {
+ if p.recordsIndex == nil {
+ return nil, fmt.Errorf("records index not available")
+ }
+
+ var posts []BlueskyPostInfo
+ cursor := ""
+ batchSize := 100
+
+ for {
+ // Get all Bluesky posts
+ records, nextCursor, err := p.recordsIndex.ListRecords(atproto.BskyPostCollection, batchSize, cursor, false)
+ if err != nil {
+ return posts, fmt.Errorf("failed to list bluesky posts: %w", err)
+ }
+
+ for _, rec := range records {
+ // Get the record bytes to check the facets
+ recordPath := rec.Collection + "/" + rec.Rkey
+ _, recBytes, err := p.GetRecordBytes(ctx, recordPath)
+ if err != nil {
+ slog.Warn("Failed to get post record bytes for export",
+ "rkey", rec.Rkey,
+ "error", err)
+ continue
+ }
+
+ if recBytes == nil {
+ continue
+ }
+
+ // Parse as FeedPost to check facets
+ var post bsky.FeedPost
+ if err := post.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
+ slog.Warn("Failed to unmarshal post record for export",
+ "rkey", rec.Rkey,
+ "error", err)
+ continue
+ }
+
+ // Check if any facet mentions this user's DID
+ if !postMentionsUser(&post, userDID) {
+ continue
+ }
+
+ posts = append(posts, BlueskyPostInfo{
+ Rkey: rec.Rkey,
+ Text: post.Text,
+ CreatedAt: post.CreatedAt,
+ })
+ }
+
+ if nextCursor == "" {
+ break
+ }
+ cursor = nextCursor
+ }
+
+ return posts, nil
+}
diff --git a/pkg/hold/pds/delete_test.go b/pkg/hold/pds/delete_test.go
new file mode 100644
index 0000000..07698fc
--- /dev/null
+++ b/pkg/hold/pds/delete_test.go
@@ -0,0 +1,337 @@
+package pds
+
+import (
+ "testing"
+
+ "atcr.io/pkg/atproto"
+ bsky "github.com/bluesky-social/indigo/api/bsky"
+)
+
+func TestPostMentionsUser(t *testing.T) {
+ tests := []struct {
+ name string
+ post *bsky.FeedPost
+ userDID string
+ expected bool
+ }{
+ {
+ name: "post mentions user",
+ post: &bsky.FeedPost{
+ Text: "@alice.bsky.social pushed myapp:latest",
+ Facets: []*bsky.RichtextFacet{{
+ Index: &bsky.RichtextFacet_ByteSlice{
+ ByteStart: 0,
+ ByteEnd: 20,
+ },
+ Features: []*bsky.RichtextFacet_Features_Elem{{
+ RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
+ Did: "did:plc:alice123",
+ },
+ }},
+ }},
+ },
+ userDID: "did:plc:alice123",
+ expected: true,
+ },
+ {
+ name: "post mentions different user",
+ post: &bsky.FeedPost{
+ Text: "@bob.bsky.social pushed myapp:latest",
+ Facets: []*bsky.RichtextFacet{{
+ Index: &bsky.RichtextFacet_ByteSlice{
+ ByteStart: 0,
+ ByteEnd: 18,
+ },
+ Features: []*bsky.RichtextFacet_Features_Elem{{
+ RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
+ Did: "did:plc:bob456",
+ },
+ }},
+ }},
+ },
+ userDID: "did:plc:alice123",
+ expected: false,
+ },
+ {
+ name: "post with no facets",
+ post: &bsky.FeedPost{
+ Text: "Just a regular post",
+ Facets: nil,
+ },
+ userDID: "did:plc:alice123",
+ expected: false,
+ },
+ {
+ name: "post with empty facets",
+ post: &bsky.FeedPost{
+ Text: "Just a regular post",
+ Facets: []*bsky.RichtextFacet{},
+ },
+ userDID: "did:plc:alice123",
+ expected: false,
+ },
+ {
+ name: "post with link facet only (no mention)",
+ post: &bsky.FeedPost{
+ Text: "Check out https://example.com",
+ Facets: []*bsky.RichtextFacet{{
+ Index: &bsky.RichtextFacet_ByteSlice{
+ ByteStart: 10,
+ ByteEnd: 30,
+ },
+ Features: []*bsky.RichtextFacet_Features_Elem{{
+ RichtextFacet_Link: &bsky.RichtextFacet_Link{
+ Uri: "https://example.com",
+ },
+ }},
+ }},
+ },
+ userDID: "did:plc:alice123",
+ expected: false,
+ },
+ {
+ name: "post with multiple mentions - match second",
+ post: &bsky.FeedPost{
+ Text: "@bob.bsky.social and @alice.bsky.social pushed",
+ Facets: []*bsky.RichtextFacet{
+ {
+ Index: &bsky.RichtextFacet_ByteSlice{
+ ByteStart: 0,
+ ByteEnd: 18,
+ },
+ Features: []*bsky.RichtextFacet_Features_Elem{{
+ RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
+ Did: "did:plc:bob456",
+ },
+ }},
+ },
+ {
+ Index: &bsky.RichtextFacet_ByteSlice{
+ ByteStart: 23,
+ ByteEnd: 43,
+ },
+ Features: []*bsky.RichtextFacet_Features_Elem{{
+ RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
+ Did: "did:plc:alice123",
+ },
+ }},
+ },
+ },
+ },
+ userDID: "did:plc:alice123",
+ expected: true,
+ },
+ {
+ name: "facet with nil features",
+ post: &bsky.FeedPost{
+ Text: "Some post",
+ Facets: []*bsky.RichtextFacet{{
+ Index: &bsky.RichtextFacet_ByteSlice{
+ ByteStart: 0,
+ ByteEnd: 4,
+ },
+ Features: nil,
+ }},
+ },
+ userDID: "did:plc:alice123",
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := postMentionsUser(tt.post, tt.userDID)
+ if result != tt.expected {
+ t.Errorf("postMentionsUser() = %v, want %v", result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestDeleteBlueskyPosts_NoPosts(t *testing.T) {
+ // Create a test PDS with records index
+ pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner")
+ defer cleanup()
+
+ ctx := sharedCtx
+
+ // Delete posts for a user that has no posts
+ deleted, err := pds.deleteBlueskyPosts(ctx, "did:plc:nonexistent")
+ if err != nil {
+ t.Fatalf("deleteBlueskyPosts() error = %v", err)
+ }
+
+ if deleted != 0 {
+ t.Errorf("deleteBlueskyPosts() deleted = %d, want 0", deleted)
+ }
+}
+
+func TestListBlueskyPostsForUser_NoPosts(t *testing.T) {
+ // Create a test PDS with records index
+ pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner")
+ defer cleanup()
+
+ ctx := sharedCtx
+
+ // List posts for a user that has no posts
+ posts, err := pds.ListBlueskyPostsForUser(ctx, "did:plc:nonexistent")
+ if err != nil {
+ t.Fatalf("ListBlueskyPostsForUser() error = %v", err)
+ }
+
+ if len(posts) != 0 {
+ t.Errorf("ListBlueskyPostsForUser() returned %d posts, want 0", len(posts))
+ }
+}
+
+func TestDeleteAndListBlueskyPosts_WithPosts(t *testing.T) {
+ // Create a test PDS with records index
+ pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner")
+ defer cleanup()
+
+ ctx := sharedCtx
+
+ // Create a test post that mentions alice
+ aliceDID := "did:plc:alice123"
+ post := &bsky.FeedPost{
+ LexiconTypeID: atproto.BskyPostCollection,
+ Text: "@alice.bsky.social pushed myapp:latest",
+ Facets: []*bsky.RichtextFacet{{
+ Index: &bsky.RichtextFacet_ByteSlice{
+ ByteStart: 0,
+ ByteEnd: 20,
+ },
+ Features: []*bsky.RichtextFacet_Features_Elem{{
+ RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
+ Did: aliceDID,
+ },
+ }},
+ }},
+ CreatedAt: "2025-01-01T00:00:00Z",
+ }
+
+ // Create the post in the PDS
+ rkey, _, err := pds.repomgr.CreateRecord(ctx, pds.uid, atproto.BskyPostCollection, post)
+ if err != nil {
+ t.Fatalf("Failed to create test post: %v", err)
+ }
+
+ // Index the record
+ if pds.recordsIndex != nil {
+ err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID)
+ if err != nil {
+ t.Fatalf("Failed to index test post: %v", err)
+ }
+ }
+
+ // List posts for alice - should find 1
+ posts, err := pds.ListBlueskyPostsForUser(ctx, aliceDID)
+ if err != nil {
+ t.Fatalf("ListBlueskyPostsForUser() error = %v", err)
+ }
+
+ if len(posts) != 1 {
+ t.Errorf("ListBlueskyPostsForUser() returned %d posts, want 1", len(posts))
+ }
+
+ if len(posts) > 0 {
+ if posts[0].Text != "@alice.bsky.social pushed myapp:latest" {
+ t.Errorf("Post text = %q, want %q", posts[0].Text, "@alice.bsky.social pushed myapp:latest")
+ }
+ if posts[0].CreatedAt != "2025-01-01T00:00:00Z" {
+ t.Errorf("Post createdAt = %q, want %q", posts[0].CreatedAt, "2025-01-01T00:00:00Z")
+ }
+ }
+
+ // List posts for bob - should find 0
+ bobPosts, err := pds.ListBlueskyPostsForUser(ctx, "did:plc:bob456")
+ if err != nil {
+ t.Fatalf("ListBlueskyPostsForUser(bob) error = %v", err)
+ }
+
+ if len(bobPosts) != 0 {
+ t.Errorf("ListBlueskyPostsForUser(bob) returned %d posts, want 0", len(bobPosts))
+ }
+
+ // Delete posts for alice
+ deleted, err := pds.deleteBlueskyPosts(ctx, aliceDID)
+ if err != nil {
+ t.Fatalf("deleteBlueskyPosts() error = %v", err)
+ }
+
+ if deleted != 1 {
+ t.Errorf("deleteBlueskyPosts() deleted = %d, want 1", deleted)
+ }
+
+ // List posts for alice again - should find 0 now
+ postsAfterDelete, err := pds.ListBlueskyPostsForUser(ctx, aliceDID)
+ if err != nil {
+ t.Fatalf("ListBlueskyPostsForUser() after delete error = %v", err)
+ }
+
+ if len(postsAfterDelete) != 0 {
+ t.Errorf("ListBlueskyPostsForUser() after delete returned %d posts, want 0", len(postsAfterDelete))
+ }
+}
+
+func TestDeleteUserData_IncludesPosts(t *testing.T) {
+ // Create a test PDS with records index
+ pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner")
+ defer cleanup()
+
+ ctx := sharedCtx
+
+ // Create a test post that mentions alice
+ aliceDID := "did:plc:alice123"
+ post := &bsky.FeedPost{
+ LexiconTypeID: atproto.BskyPostCollection,
+ Text: "@alice.bsky.social pushed myapp:latest",
+ Facets: []*bsky.RichtextFacet{{
+ Index: &bsky.RichtextFacet_ByteSlice{
+ ByteStart: 0,
+ ByteEnd: 20,
+ },
+ Features: []*bsky.RichtextFacet_Features_Elem{{
+ RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
+ Did: aliceDID,
+ },
+ }},
+ }},
+ CreatedAt: "2025-01-01T00:00:00Z",
+ }
+
+ // Create the post in the PDS
+ rkey, _, err := pds.repomgr.CreateRecord(ctx, pds.uid, atproto.BskyPostCollection, post)
+ if err != nil {
+ t.Fatalf("Failed to create test post: %v", err)
+ }
+
+ // Index the record
+ if pds.recordsIndex != nil {
+ err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID)
+ if err != nil {
+ t.Fatalf("Failed to index test post: %v", err)
+ }
+ }
+
+ // Call DeleteUserData
+ result, err := pds.DeleteUserData(ctx, aliceDID)
+ if err != nil {
+ t.Fatalf("DeleteUserData() error = %v", err)
+ }
+
+ // Verify posts were deleted
+ if result.PostsDeleted != 1 {
+ t.Errorf("DeleteUserData() PostsDeleted = %d, want 1", result.PostsDeleted)
+ }
+
+ // Verify post is actually gone
+ posts, err := pds.ListBlueskyPostsForUser(ctx, aliceDID)
+ if err != nil {
+ t.Fatalf("ListBlueskyPostsForUser() error = %v", err)
+ }
+
+ if len(posts) != 0 {
+ t.Errorf("Posts still exist after DeleteUserData, count = %d", len(posts))
+ }
+}
diff --git a/pkg/hold/pds/xrpc.go b/pkg/hold/pds/xrpc.go
index 7561198..e5df07a 100644
--- a/pkg/hold/pds/xrpc.go
+++ b/pkg/hold/pds/xrpc.go
@@ -1499,13 +1499,14 @@ func (h *XRPCHandler) HandleGetQuota(w http.ResponseWriter, r *http.Request) {
// HoldUserDataExport represents the GDPR data export from a hold service
type HoldUserDataExport struct {
- ExportedAt time.Time `json:"exported_at"`
- HoldDID string `json:"hold_did"`
- UserDID string `json:"user_did"`
- IsCaptain bool `json:"is_captain"`
- CrewRecord *CrewExport `json:"crew_record,omitempty"`
- LayerRecords []LayerExport `json:"layer_records"`
- StatsRecords []StatsExport `json:"stats_records"`
+ ExportedAt time.Time `json:"exported_at"`
+ HoldDID string `json:"hold_did"`
+ UserDID string `json:"user_did"`
+ IsCaptain bool `json:"is_captain"`
+ CrewRecord *CrewExport `json:"crew_record,omitempty"`
+ LayerRecords []LayerExport `json:"layer_records"`
+ StatsRecords []StatsExport `json:"stats_records"`
+ BlueskyPosts []BlueskyPostExport `json:"bluesky_posts"`
}
// CrewExport represents a sanitized crew record for export
@@ -1535,6 +1536,13 @@ type StatsExport struct {
UpdatedAt string `json:"updated_at"`
}
+// BlueskyPostExport represents a Bluesky post that mentions the user
+type BlueskyPostExport struct {
+ URI string `json:"uri"` // at://did/app.bsky.feed.post/rkey
+ Text string `json:"text"` // Post content
+ CreatedAt string `json:"created_at"` // When the post was created
+}
+
// HandleExportUserData handles GDPR data export requests for a specific user.
// This endpoint returns all records stored on this hold's PDS that reference
// the authenticated user's DID.
@@ -1543,6 +1551,7 @@ type StatsExport struct {
// - io.atcr.hold.layer records where userDid matches
// - io.atcr.hold.crew record for the DID (if exists)
// - io.atcr.hold.stats records where ownerDid matches
+// - app.bsky.feed.post records that mention the user
// - Whether the user is the hold captain
//
// Authentication: Requires valid service token from user's PDS
@@ -1564,6 +1573,7 @@ func (h *XRPCHandler) HandleExportUserData(w http.ResponseWriter, r *http.Reques
UserDID: user.DID,
LayerRecords: []LayerExport{},
StatsRecords: []StatsExport{},
+ BlueskyPosts: []BlueskyPostExport{},
}
// Check if user is captain
@@ -1622,13 +1632,31 @@ func (h *XRPCHandler) HandleExportUserData(w http.ResponseWriter, r *http.Reques
}
}
+ // Get Bluesky posts that mention this user (GDPR compliance)
+ blueskyPosts, err := h.pds.ListBlueskyPostsForUser(r.Context(), user.DID)
+ if err != nil {
+ slog.Warn("Failed to get bluesky posts for export",
+ "user_did", user.DID,
+ "error", err)
+ // Continue with empty list - don't fail entire export
+ } else {
+ for _, post := range blueskyPosts {
+ export.BlueskyPosts = append(export.BlueskyPosts, BlueskyPostExport{
+ URI: fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), atproto.BskyPostCollection, post.Rkey),
+ Text: post.Text,
+ CreatedAt: post.CreatedAt,
+ })
+ }
+ }
+
slog.Info("GDPR data export completed",
"user_did", user.DID,
"hold_did", h.pds.DID(),
"is_captain", export.IsCaptain,
"has_crew_record", export.CrewRecord != nil,
"layer_count", len(export.LayerRecords),
- "stats_count", len(export.StatsRecords))
+ "stats_count", len(export.StatsRecords),
+ "post_count", len(export.BlueskyPosts))
render.JSON(w, r, export)
}