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

This commit is contained in:
Evan Jarrett
2026-01-10 15:24:28 -06:00
parent 3155f91e3a
commit fa9abc28b9
5 changed files with 642 additions and 18 deletions

View File

@@ -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
}

View File

@@ -21,7 +21,7 @@
<h3>Data Stored on Our Infrastructure</h3>
<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>
<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>
<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>
@@ -43,6 +43,33 @@
<p><strong>Server Logs:</strong> 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.</p>
</div>
<div class="legal-section">
<h2>Our Services and Their Data</h2>
<p>AT Container Registry consists of multiple services, each with distinct data responsibilities:</p>
<h3>AppView (atcr.io)</h3>
<p>The registry frontend you interact with directly. Stores:</p>
<ul>
<li>OAuth sessions and tokens for authentication</li>
<li>Device tokens for the Docker credential helper</li>
<li>Web UI sessions</li>
<li>Cached metadata from your PDS (indexes for search and display)</li>
</ul>
<h3>ATCR-Hosted Hold Services</h3>
<p>Storage backends we operate (e.g., <code>hold01.atcr.io</code>). Each hold has an embedded PDS and stores:</p>
<ul>
<li>OCI blobs (container image layers) in object storage</li>
<li>Layer records in the hold's embedded PDS linking your DID to blob references</li>
<li>Crew membership records for access control</li>
</ul>
<p>Hold services on <code>*.atcr.io</code> domains are operated by us and covered by this policy.</p>
<h3>User-Deployed Hold Services (BYOS)</h3>
<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>
</div>
<div class="legal-section">
<h2>Data Sharing and Deduplication</h2>
@@ -75,17 +102,28 @@
<h3>Right to Erasure ("Right to be Forgotten")</h3>
<p>You may request deletion of your data via the account settings page. Due to our technical architecture, deletion works as follows:</p>
<p><strong>Immediately deleted:</strong></p>
<p><strong>Immediately deleted from AppView:</strong></p>
<ul>
<li>Layer records on our PDS that reference your DID</li>
<li>OAuth tokens, web UI sessions, and device tokens</li>
<li>Cached PDS data</li>
<li>Cached PDS data (manifest and tag indexes)</li>
<li>Server logs containing your identifiers (deleted or anonymized, if retained)</li>
</ul>
<p><strong>Deleted within 30 days:</strong></p>
<p><strong>Immediately deleted from ATCR-hosted holds:</strong></p>
<ul>
<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>
<li>Layer records in the hold's embedded PDS that reference your DID</li>
<li>Crew membership records</li>
</ul>
<p><strong>Deleted within 30 days from ATCR-hosted holds:</strong></p>
<ul>
<li>OCI blobs in object storage that are no longer referenced by any user (via garbage collection)</li>
</ul>
<p><strong>User-deployed holds:</strong></p>
<ul>
<li>We attempt to delete your data via API, but success depends on hold availability</li>
<li>Data on holds we do not operate is governed by that operator's policies</li>
</ul>
<p><strong>Cannot be deleted by us:</strong></p>
@@ -174,37 +212,45 @@
<thead>
<tr>
<th>Data Type</th>
<th>Service</th>
<th>Retention Period</th>
</tr>
</thead>
<tbody>
<tr>
<td>OAuth tokens</td>
<td>AppView</td>
<td>Until revoked or logout</td>
</tr>
<tr>
<td>Web UI session tokens</td>
<td>AppView</td>
<td>Until logout or expiration</td>
</tr>
<tr>
<td>Device tokens (credential helper)</td>
<td>AppView</td>
<td>Until revoked by user</td>
</tr>
<tr>
<td>Cached PDS data</td>
<td>AppView</td>
<td>Refreshed periodically; deleted on account deletion</td>
</tr>
<tr>
<td>Server logs</td>
<td>AppView</td>
<td>Currently ephemeral; this policy will be updated if log retention is implemented</td>
</tr>
<tr>
<td>Layer records (our PDS)</td>
<td>Layer records</td>
<td>Hold PDS</td>
<td>Until you request deletion</td>
</tr>
<tr>
<td>OCI blobs</td>
<td>Until no longer referenced (pruned monthly)</td>
<td>Hold Storage</td>
<td>Until no longer referenced (pruned within 30 days)</td>
</tr>
</tbody>
</table>
@@ -223,6 +269,32 @@
</ol>
</div>
<div class="legal-section">
<h2>Bring Your Own Storage (BYOS)</h2>
<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>
<h3>ATCR-Hosted Holds</h3>
<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>
<h3>User-Deployed Holds</h3>
<p>If you use a hold service not operated by us:</p>
<ul>
<li>That hold's data practices are governed by its operator's privacy policy, not ours</li>
<li>When you request account deletion, we attempt to delete your data from all holds via API</li>
<li>We cannot guarantee deletion for holds that are offline or refuse the request</li>
<li>You should contact that hold's operator directly for data requests we cannot fulfill</li>
</ul>
<h3>If You Operate a Hold</h3>
<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>
<ul>
<li>Responding to deletion requests from users of your hold</li>
<li>Implementing appropriate data retention policies</li>
<li>Publishing your own privacy policy if required by law</li>
</ul>
</div>
<div class="legal-section">
<h2>How to Exercise Your Rights</h2>

View File

@@ -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
}

337
pkg/hold/pds/delete_test.go Normal file
View File

@@ -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))
}
}

View File

@@ -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)
}