From 64cdb669575638a8aa1dfe447f2aa65f91d828fe Mon Sep 17 00:00:00 2001
From: Evan Jarrett
Date: Thu, 8 Jan 2026 23:17:38 -0600
Subject: [PATCH] begin delete my account implementation
---
pkg/appview/db/delete.go | 80 +++++
pkg/appview/db/delete_test.go | 306 ++++++++++++++++++
pkg/appview/handlers/delete.go | 344 ++++++++++++++++++++
pkg/appview/handlers/delete_test.go | 318 ++++++++++++++++++
pkg/appview/middleware/auth.go | 7 +
pkg/appview/routes/routes.go | 7 +
pkg/appview/templates/pages/settings.html | 378 ++++++++++++++++++++++
pkg/atproto/client.go | 89 +++++
pkg/hold/pds/delete.go | 192 +++++++++++
pkg/hold/pds/xrpc.go | 79 ++++-
10 files changed, 1799 insertions(+), 1 deletion(-)
create mode 100644 pkg/appview/db/delete.go
create mode 100644 pkg/appview/db/delete_test.go
create mode 100644 pkg/appview/handlers/delete.go
create mode 100644 pkg/appview/handlers/delete_test.go
create mode 100644 pkg/hold/pds/delete.go
diff --git a/pkg/appview/db/delete.go b/pkg/appview/db/delete.go
new file mode 100644
index 0000000..b86fd58
--- /dev/null
+++ b/pkg/appview/db/delete.go
@@ -0,0 +1,80 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "log/slog"
+)
+
+// DeleteUserDataFull performs complete user deletion including non-cascading tables.
+// This is the main function for GDPR account deletion.
+//
+// Order of operations:
+// 1. Delete hold membership data (non-cascading tables)
+// 2. Delete OAuth sessions
+// 3. Delete user (cascades to manifests, tags, stars, repo_pages, etc.)
+//
+// This should be called AFTER remote cleanup (hold services, PDS records)
+// since we need the OAuth tokens to authenticate those requests.
+func DeleteUserDataFull(db *sql.DB, oauthStore *OAuthStore, did string) error {
+ slog.Info("Starting full user data deletion", "did", did)
+
+ // 1. Delete non-cascading hold membership tables
+ if err := deleteHoldMembershipData(db, did); err != nil {
+ slog.Error("Failed to delete hold membership data", "did", did, "error", err)
+ return fmt.Errorf("failed to delete hold membership data: %w", err)
+ }
+
+ // 2. Delete OAuth sessions
+ if oauthStore != nil {
+ if err := oauthStore.DeleteSessionsForDID(context.Background(), did); err != nil {
+ slog.Warn("Failed to delete OAuth sessions", "did", did, "error", err)
+ // Continue - not critical
+ } else {
+ slog.Debug("Deleted OAuth sessions", "did", did)
+ }
+ }
+
+ // 3. Delete user (cascades to manifests, tags, stars, annotations, etc.)
+ if err := DeleteUserData(db, did); err != nil {
+ slog.Error("Failed to delete user data", "did", did, "error", err)
+ return fmt.Errorf("failed to delete user data: %w", err)
+ }
+
+ slog.Info("User data deletion completed", "did", did)
+ return nil
+}
+
+// deleteHoldMembershipData deletes non-cascading hold membership tables.
+// These tables don't have foreign keys to the users table.
+func deleteHoldMembershipData(db *sql.DB, did string) error {
+ // Delete from hold_crew_approvals (where user is the approved member)
+ result, err := db.Exec(`DELETE FROM hold_crew_approvals WHERE user_did = ?`, did)
+ if err != nil {
+ return fmt.Errorf("failed to delete crew approvals: %w", err)
+ }
+ approvalsDeleted, _ := result.RowsAffected()
+
+ // Delete from hold_crew_denials (where user was denied)
+ result, err = db.Exec(`DELETE FROM hold_crew_denials WHERE user_did = ?`, did)
+ if err != nil {
+ return fmt.Errorf("failed to delete crew denials: %w", err)
+ }
+ denialsDeleted, _ := result.RowsAffected()
+
+ // Delete from hold_crew_members (cached crew memberships)
+ result, err = db.Exec(`DELETE FROM hold_crew_members WHERE member_did = ?`, did)
+ if err != nil {
+ return fmt.Errorf("failed to delete crew members: %w", err)
+ }
+ membersDeleted, _ := result.RowsAffected()
+
+ slog.Debug("Deleted hold membership data",
+ "did", did,
+ "approvals_deleted", approvalsDeleted,
+ "denials_deleted", denialsDeleted,
+ "members_deleted", membersDeleted)
+
+ return nil
+}
diff --git a/pkg/appview/db/delete_test.go b/pkg/appview/db/delete_test.go
new file mode 100644
index 0000000..3781d2b
--- /dev/null
+++ b/pkg/appview/db/delete_test.go
@@ -0,0 +1,306 @@
+package db
+
+import (
+ "fmt"
+ "testing"
+ "time"
+)
+
+func TestDeleteUserDataFull_DeletesAllData(t *testing.T) {
+ db, err := InitDB(":memory:")
+ if err != nil {
+ t.Fatalf("Failed to init database: %v", err)
+ }
+ defer db.Close()
+
+ // Create test user
+ testUser := &User{
+ DID: "did:plc:test123",
+ Handle: "test.bsky.social",
+ PDSEndpoint: "https://bsky.social",
+ LastSeen: time.Now(),
+ }
+ if err := UpsertUser(db, testUser); err != nil {
+ t.Fatalf("Failed to create user: %v", err)
+ }
+
+ // Create manifest
+ _, err = db.Exec(`
+ INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2,
+ "application/vnd.oci.image.manifest.v1+json", time.Now())
+ if err != nil {
+ t.Fatalf("Failed to create manifest: %v", err)
+ }
+
+ // Create tag
+ _, err = db.Exec(`
+ INSERT INTO tags (did, repository, tag, digest, created_at)
+ VALUES (?, ?, ?, ?, ?)
+ `, testUser.DID, "myapp", "latest", "sha256:abc123", time.Now())
+ if err != nil {
+ t.Fatalf("Failed to create tag: %v", err)
+ }
+
+ // Create hold membership data (non-cascading)
+ _, err = db.Exec(`
+ INSERT INTO hold_crew_approvals (hold_did, user_did, approved_at, expires_at)
+ VALUES (?, ?, ?, ?)
+ `, "did:web:hold.example.com", testUser.DID, time.Now(), time.Now().Add(24*time.Hour))
+ if err != nil {
+ t.Fatalf("Failed to create crew approval: %v", err)
+ }
+
+ _, err = db.Exec(`
+ INSERT INTO hold_crew_members (hold_did, member_did, rkey, permissions)
+ VALUES (?, ?, ?, ?)
+ `, "did:web:hold.example.com", testUser.DID, "member1", `["blob:read","blob:write"]`)
+ if err != nil {
+ t.Fatalf("Failed to create crew member: %v", err)
+ }
+
+ // Create OAuth store
+ oauthStore := NewOAuthStore(db)
+
+ // Delete all user data
+ err = DeleteUserDataFull(db, oauthStore, testUser.DID)
+ if err != nil {
+ t.Fatalf("DeleteUserDataFull failed: %v", err)
+ }
+
+ // Verify user was deleted
+ var count int
+ err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", testUser.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query users: %v", err)
+ }
+ if count != 0 {
+ t.Error("Expected user to be deleted")
+ }
+
+ // Verify manifests were cascade deleted
+ err = db.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ?", testUser.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query manifests: %v", err)
+ }
+ if count != 0 {
+ t.Error("Expected manifests to be cascade deleted")
+ }
+
+ // Verify tags were cascade deleted
+ err = db.QueryRow("SELECT COUNT(*) FROM tags WHERE did = ?", testUser.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query tags: %v", err)
+ }
+ if count != 0 {
+ t.Error("Expected tags to be cascade deleted")
+ }
+
+ // Verify hold membership data was deleted
+ err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_approvals WHERE user_did = ?", testUser.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query crew approvals: %v", err)
+ }
+ if count != 0 {
+ t.Error("Expected crew approvals to be deleted")
+ }
+
+ err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_members WHERE member_did = ?", testUser.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query crew members: %v", err)
+ }
+ if count != 0 {
+ t.Error("Expected crew members to be deleted")
+ }
+}
+
+func TestDeleteUserDataFull_DoesNotAffectOtherUsers(t *testing.T) {
+ db, err := InitDB(":memory:")
+ if err != nil {
+ t.Fatalf("Failed to init database: %v", err)
+ }
+ defer db.Close()
+
+ // Create two users
+ user1 := &User{
+ DID: "did:plc:user1",
+ Handle: "user1.bsky.social",
+ PDSEndpoint: "https://bsky.social",
+ LastSeen: time.Now(),
+ }
+ user2 := &User{
+ DID: "did:plc:user2",
+ Handle: "user2.bsky.social",
+ PDSEndpoint: "https://bsky.social",
+ LastSeen: time.Now(),
+ }
+ if err := UpsertUser(db, user1); err != nil {
+ t.Fatalf("Failed to create user1: %v", err)
+ }
+ if err := UpsertUser(db, user2); err != nil {
+ t.Fatalf("Failed to create user2: %v", err)
+ }
+
+ // Create manifests for both users
+ for _, user := range []*User{user1, user2} {
+ _, err = db.Exec(`
+ INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `, user.DID, "myapp", "sha256:"+user.DID, "did:web:hold.example.com", 2,
+ "application/vnd.oci.image.manifest.v1+json", time.Now())
+ if err != nil {
+ t.Fatalf("Failed to create manifest for %s: %v", user.Handle, err)
+ }
+ }
+
+ // Create hold membership data for both users
+ for i, user := range []*User{user1, user2} {
+ _, err = db.Exec(`
+ INSERT INTO hold_crew_members (hold_did, member_did, rkey, permissions)
+ VALUES (?, ?, ?, ?)
+ `, "did:web:hold.example.com", user.DID, fmt.Sprintf("member%d", i+1), `["blob:read"]`)
+ if err != nil {
+ t.Fatalf("Failed to create crew member for %s: %v", user.Handle, err)
+ }
+ }
+
+ oauthStore := NewOAuthStore(db)
+
+ // Delete only user1's data
+ err = DeleteUserDataFull(db, oauthStore, user1.DID)
+ if err != nil {
+ t.Fatalf("DeleteUserDataFull failed: %v", err)
+ }
+
+ // Verify user1 was deleted
+ var count int
+ err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", user1.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query users: %v", err)
+ }
+ if count != 0 {
+ t.Error("Expected user1 to be deleted")
+ }
+
+ // Verify user2 still exists
+ err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", user2.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query users: %v", err)
+ }
+ if count != 1 {
+ t.Error("Expected user2 to still exist")
+ }
+
+ // Verify user2's manifests still exist
+ err = db.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ?", user2.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query manifests: %v", err)
+ }
+ if count != 1 {
+ t.Error("Expected user2's manifest to still exist")
+ }
+
+ // Verify user2's crew membership still exists
+ err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_members WHERE member_did = ?", user2.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query crew members: %v", err)
+ }
+ if count != 1 {
+ t.Error("Expected user2's crew membership to still exist")
+ }
+}
+
+func TestDeleteUserDataFull_HandlesNonExistentUser(t *testing.T) {
+ db, err := InitDB(":memory:")
+ if err != nil {
+ t.Fatalf("Failed to init database: %v", err)
+ }
+ defer db.Close()
+
+ oauthStore := NewOAuthStore(db)
+
+ // Try to delete non-existent user - should not error
+ err = DeleteUserDataFull(db, oauthStore, "did:plc:nonexistent")
+ if err != nil {
+ t.Errorf("Expected no error for non-existent user, got: %v", err)
+ }
+}
+
+func TestDeleteUserDataFull_WithNilOAuthStore(t *testing.T) {
+ db, err := InitDB(":memory:")
+ if err != nil {
+ t.Fatalf("Failed to init database: %v", err)
+ }
+ defer db.Close()
+
+ testUser := &User{
+ DID: "did:plc:test123",
+ Handle: "test.bsky.social",
+ PDSEndpoint: "https://bsky.social",
+ LastSeen: time.Now(),
+ }
+ if err := UpsertUser(db, testUser); err != nil {
+ t.Fatalf("Failed to create user: %v", err)
+ }
+
+ // Delete with nil OAuth store - should still work
+ err = DeleteUserDataFull(db, nil, testUser.DID)
+ if err != nil {
+ t.Errorf("Expected no error with nil OAuth store, got: %v", err)
+ }
+
+ // Verify user was deleted
+ var count int
+ err = db.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", testUser.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query users: %v", err)
+ }
+ if count != 0 {
+ t.Error("Expected user to be deleted")
+ }
+}
+
+func TestDeleteUserDataFull_DeletesDenials(t *testing.T) {
+ db, err := InitDB(":memory:")
+ if err != nil {
+ t.Fatalf("Failed to init database: %v", err)
+ }
+ defer db.Close()
+
+ testUser := &User{
+ DID: "did:plc:test123",
+ Handle: "test.bsky.social",
+ PDSEndpoint: "https://bsky.social",
+ LastSeen: time.Now(),
+ }
+ if err := UpsertUser(db, testUser); err != nil {
+ t.Fatalf("Failed to create user: %v", err)
+ }
+
+ // Create denial record
+ _, err = db.Exec(`
+ INSERT INTO hold_crew_denials (hold_did, user_did, denial_count, next_retry_at, last_denied_at)
+ VALUES (?, ?, ?, ?, ?)
+ `, "did:web:hold.example.com", testUser.DID, 1, time.Now().Add(24*time.Hour), time.Now())
+ if err != nil {
+ t.Fatalf("Failed to create crew denial: %v", err)
+ }
+
+ oauthStore := NewOAuthStore(db)
+
+ err = DeleteUserDataFull(db, oauthStore, testUser.DID)
+ if err != nil {
+ t.Fatalf("DeleteUserDataFull failed: %v", err)
+ }
+
+ // Verify denial was deleted
+ var count int
+ err = db.QueryRow("SELECT COUNT(*) FROM hold_crew_denials WHERE user_did = ?", testUser.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query crew denials: %v", err)
+ }
+ if count != 0 {
+ t.Error("Expected crew denials to be deleted")
+ }
+}
diff --git a/pkg/appview/handlers/delete.go b/pkg/appview/handlers/delete.go
new file mode 100644
index 0000000..7edf359
--- /dev/null
+++ b/pkg/appview/handlers/delete.go
@@ -0,0 +1,344 @@
+package handlers
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "sync"
+ "time"
+
+ "atcr.io/pkg/appview/db"
+ "atcr.io/pkg/appview/middleware"
+ "atcr.io/pkg/atproto"
+ "atcr.io/pkg/auth"
+ "atcr.io/pkg/auth/oauth"
+)
+
+// DeleteAccountRequest represents the GDPR account deletion request
+type DeleteAccountRequest struct {
+ DeletePDSRecords bool `json:"delete_pds_records"`
+ Confirmation string `json:"confirmation"` // Must be "DELETE " to confirm
+}
+
+// DeleteAccountResponse represents the result of account deletion
+type DeleteAccountResponse struct {
+ Success bool `json:"success"`
+ AppViewDeleted bool `json:"appview_deleted"`
+ PDSDeleted bool `json:"pds_deleted,omitempty"`
+ PDSCollections map[string]int `json:"pds_collections_deleted,omitempty"`
+ HoldResults []HoldDeleteResult `json:"hold_results"`
+ Errors []string `json:"errors,omitempty"`
+}
+
+// HoldDeleteResult represents the result of deleting data from a single hold
+type HoldDeleteResult struct {
+ HoldDID string `json:"hold_did"`
+ Relationship string `json:"relationship"` // "captain" or "crew_member"
+ Status string `json:"status"` // "success", "failed", "offline"
+ Error string `json:"error,omitempty"`
+ CrewDeleted bool `json:"crew_deleted,omitempty"`
+ LayersDeleted int `json:"layers_deleted,omitempty"`
+ StatsDeleted int `json:"stats_deleted,omitempty"`
+}
+
+// DeleteAccountHandler handles GDPR account deletion requests
+type DeleteAccountHandler struct {
+ DB *sql.DB
+ OAuthStore *db.OAuthStore
+ Refresher *oauth.Refresher
+}
+
+func (h *DeleteAccountHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // Get authenticated user from middleware
+ user := middleware.GetUser(r)
+ if user == nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ // Parse request body
+ var req DeleteAccountRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ // Require confirmation with handle (e.g., "DELETE alice.bsky.social")
+ expectedConfirmation := "DELETE " + user.Handle
+ if req.Confirmation != expectedConfirmation {
+ http.Error(w, fmt.Sprintf("Confirmation required: must send confirmation='DELETE %s'", user.Handle), http.StatusBadRequest)
+ return
+ }
+
+ slog.Info("Processing account deletion request",
+ "component", "delete",
+ "did", user.DID,
+ "delete_pds_records", req.DeletePDSRecords)
+
+ response := DeleteAccountResponse{
+ HoldResults: []HoldDeleteResult{},
+ }
+
+ // 1. Delete from each hold where user is a member
+ holdResults := h.deleteFromHolds(r.Context(), user)
+ response.HoldResults = holdResults
+
+ // 2. If requested, delete PDS records
+ if req.DeletePDSRecords {
+ pdsResults, err := h.deletePDSRecords(r.Context(), user)
+ if err != nil {
+ slog.Error("Failed to delete PDS records",
+ "component", "delete",
+ "did", user.DID,
+ "error", err)
+ response.Errors = append(response.Errors, fmt.Sprintf("PDS deletion error: %v", err))
+ } else {
+ response.PDSDeleted = true
+ response.PDSCollections = pdsResults
+ }
+ }
+
+ // 3. Delete from AppView database (last, since we need OAuth tokens for above steps)
+ if err := db.DeleteUserDataFull(h.DB, h.OAuthStore, user.DID); err != nil {
+ slog.Error("Failed to delete AppView data",
+ "component", "delete",
+ "did", user.DID,
+ "error", err)
+ response.Errors = append(response.Errors, fmt.Sprintf("AppView deletion error: %v", err))
+ } else {
+ response.AppViewDeleted = true
+ }
+
+ // Set success if AppView data was deleted (main requirement)
+ response.Success = response.AppViewDeleted
+
+ slog.Info("Account deletion completed",
+ "component", "delete",
+ "did", user.DID,
+ "success", response.Success,
+ "holds_processed", len(response.HoldResults),
+ "pds_deleted", response.PDSDeleted)
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(response); err != nil {
+ slog.Error("Failed to encode response", "error", err)
+ }
+}
+
+// deleteFromHolds deletes user data from all holds where they are a member
+func (h *DeleteAccountHandler) deleteFromHolds(ctx context.Context, user *db.User) []HoldDeleteResult {
+ var results []HoldDeleteResult
+
+ // Build metadata map: holdDID → relationship
+ holdMeta := make(map[string]string)
+
+ // Get holds where user is captain
+ if h.DB != nil {
+ captainHolds, err := db.GetCaptainRecordsForOwner(h.DB, user.DID)
+ if err != nil {
+ slog.Warn("Failed to get captain records for deletion",
+ "component", "delete",
+ "did", user.DID,
+ "error", err)
+ } else {
+ for _, hold := range captainHolds {
+ holdMeta[hold.HoldDID] = "captain"
+ }
+ }
+ }
+
+ // Get crew memberships from database
+ memberships, err := db.GetCrewMemberships(h.DB, user.DID)
+ if err != nil {
+ slog.Warn("Failed to get crew memberships for deletion",
+ "component", "delete",
+ "did", user.DID,
+ "error", err)
+ } else {
+ for _, m := range memberships {
+ // Don't overwrite captain relationship
+ if _, exists := holdMeta[m.HoldDID]; !exists {
+ holdMeta[m.HoldDID] = "crew_member"
+ }
+ }
+ }
+
+ if len(holdMeta) == 0 {
+ return results
+ }
+
+ // Delete from each hold concurrently with timeout
+ var wg sync.WaitGroup
+ resultChan := make(chan HoldDeleteResult, len(holdMeta))
+
+ for holdDID, relationship := range holdMeta {
+ wg.Add(1)
+ go func(holdDID, relationship string) {
+ defer wg.Done()
+ result := h.deleteFromSingleHold(ctx, user, holdDID, relationship)
+ resultChan <- result
+ }(holdDID, relationship)
+ }
+
+ // Wait for all goroutines to complete
+ wg.Wait()
+ close(resultChan)
+
+ // Collect results
+ for result := range resultChan {
+ results = append(results, result)
+ }
+
+ return results
+}
+
+// deleteFromSingleHold deletes user data from a single hold
+func (h *DeleteAccountHandler) deleteFromSingleHold(ctx context.Context, user *db.User, holdDID, relationship string) HoldDeleteResult {
+ // Resolve hold DID to URL
+ holdURL := atproto.ResolveHoldURL(holdDID)
+ endpoint := holdURL + "/xrpc/io.atcr.hold.deleteUserData"
+
+ result := HoldDeleteResult{
+ HoldDID: holdDID,
+ Relationship: relationship,
+ Status: "failed",
+ }
+
+ // Check if we have OAuth refresher (needed for service tokens)
+ if h.Refresher == nil {
+ result.Error = "OAuth not configured - cannot authenticate to hold"
+ return result
+ }
+
+ // Create context with timeout (10 seconds per hold for deletion)
+ timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+
+ // Get service token from user's PDS
+ serviceToken, err := auth.GetOrFetchServiceToken(timeoutCtx, h.Refresher, user.DID, holdDID, user.PDSEndpoint)
+ if err != nil {
+ slog.Warn("Failed to get service token for hold deletion",
+ "component", "delete",
+ "hold_did", holdDID,
+ "user_did", user.DID,
+ "error", err)
+ result.Error = fmt.Sprintf("Failed to authenticate: %v", err)
+ return result
+ }
+
+ // Create request
+ req, err := http.NewRequestWithContext(timeoutCtx, "DELETE", endpoint, nil)
+ if err != nil {
+ result.Error = fmt.Sprintf("Failed to create request: %v", err)
+ return result
+ }
+
+ // Set auth header
+ req.Header.Set("Authorization", "Bearer "+serviceToken)
+
+ // Make request
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ slog.Warn("Hold deletion request failed",
+ "component", "delete",
+ "hold_did", holdDID,
+ "endpoint", endpoint,
+ "error", err)
+ result.Status = "offline"
+ result.Error = fmt.Sprintf("Could not contact hold: %v", err)
+ return result
+ }
+ defer resp.Body.Close()
+
+ // Check response status
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ result.Error = fmt.Sprintf("Hold returned status %d: %s", resp.StatusCode, string(body))
+ return result
+ }
+
+ // Parse response
+ var holdResponse struct {
+ Success bool `json:"success"`
+ CrewDeleted bool `json:"crew_deleted"`
+ LayersDeleted int `json:"layers_deleted"`
+ StatsDeleted int `json:"stats_deleted"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&holdResponse); err != nil {
+ result.Error = fmt.Sprintf("Failed to parse response: %v", err)
+ return result
+ }
+
+ // Update result with success data
+ result.Status = "success"
+ result.CrewDeleted = holdResponse.CrewDeleted
+ result.LayersDeleted = holdResponse.LayersDeleted
+ result.StatsDeleted = holdResponse.StatsDeleted
+
+ slog.Debug("Successfully deleted data from hold",
+ "component", "delete",
+ "hold_did", holdDID,
+ "user_did", user.DID,
+ "crew_deleted", holdResponse.CrewDeleted,
+ "layers_deleted", holdResponse.LayersDeleted,
+ "stats_deleted", holdResponse.StatsDeleted)
+
+ return result
+}
+
+// deletePDSRecords deletes all io.atcr.* records from the user's PDS
+func (h *DeleteAccountHandler) deletePDSRecords(ctx context.Context, user *db.User) (map[string]int, error) {
+ if h.Refresher == nil {
+ return nil, fmt.Errorf("OAuth not configured")
+ }
+
+ results := make(map[string]int)
+
+ // Create ATProto client with session provider
+ client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
+
+ // Collections to delete
+ collections := []string{
+ atproto.ManifestCollection, // io.atcr.manifest
+ atproto.TagCollection, // io.atcr.tag
+ atproto.StarCollection, // io.atcr.sailor.star
+ atproto.RepoPageCollection, // io.atcr.repo.page
+ }
+
+ for _, collection := range collections {
+ deleted, err := client.DeleteAllRecordsInCollection(ctx, collection)
+ if err != nil {
+ slog.Warn("Failed to delete records in collection",
+ "component", "delete",
+ "did", user.DID,
+ "collection", collection,
+ "error", err)
+ // Continue with other collections
+ }
+ results[collection] = deleted
+ if deleted > 0 {
+ slog.Debug("Deleted records from collection",
+ "component", "delete",
+ "did", user.DID,
+ "collection", collection,
+ "count", deleted)
+ }
+ }
+
+ // Delete sailor profile (single record at rkey "self")
+ err := client.DeleteRecord(ctx, atproto.SailorProfileCollection, "self")
+ if err != nil {
+ slog.Warn("Failed to delete sailor profile",
+ "component", "delete",
+ "did", user.DID,
+ "error", err)
+ } else {
+ results[atproto.SailorProfileCollection] = 1
+ }
+
+ return results, nil
+}
diff --git a/pkg/appview/handlers/delete_test.go b/pkg/appview/handlers/delete_test.go
new file mode 100644
index 0000000..8fb019b
--- /dev/null
+++ b/pkg/appview/handlers/delete_test.go
@@ -0,0 +1,318 @@
+package handlers
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "atcr.io/pkg/appview/db"
+ "atcr.io/pkg/appview/middleware"
+ _ "github.com/mattn/go-sqlite3"
+)
+
+func TestDeleteAccountHandler_Unauthorized(t *testing.T) {
+ database := setupTestDB(t)
+ defer database.Close()
+
+ handler := &DeleteAccountHandler{
+ DB: database,
+ OAuthStore: nil,
+ Refresher: nil,
+ }
+
+ reqBody := DeleteAccountRequest{
+ DeletePDSRecords: false,
+ Confirmation: "DELETE test.bsky.social",
+ }
+ body, _ := json.Marshal(reqBody)
+ req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusUnauthorized {
+ t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code)
+ }
+}
+
+func TestDeleteAccountHandler_MissingConfirmation(t *testing.T) {
+ database := setupTestDB(t)
+ defer database.Close()
+
+ // Create test user
+ testUser := &db.User{
+ DID: "did:plc:test123",
+ Handle: "test.bsky.social",
+ PDSEndpoint: "https://bsky.social",
+ LastSeen: time.Now(),
+ }
+ if err := db.UpsertUser(database, testUser); err != nil {
+ t.Fatalf("Failed to create user: %v", err)
+ }
+
+ handler := &DeleteAccountHandler{
+ DB: database,
+ OAuthStore: nil,
+ Refresher: nil,
+ }
+
+ // Request without confirmation
+ reqBody := DeleteAccountRequest{
+ DeletePDSRecords: false,
+ Confirmation: "",
+ }
+ body, _ := json.Marshal(reqBody)
+ req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req = middleware.WithUser(req, testUser)
+
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code)
+ }
+}
+
+func TestDeleteAccountHandler_WrongConfirmation(t *testing.T) {
+ database := setupTestDB(t)
+ defer database.Close()
+
+ testUser := &db.User{
+ DID: "did:plc:test123",
+ Handle: "test.bsky.social",
+ PDSEndpoint: "https://bsky.social",
+ LastSeen: time.Now(),
+ }
+ if err := db.UpsertUser(database, testUser); err != nil {
+ t.Fatalf("Failed to create user: %v", err)
+ }
+
+ handler := &DeleteAccountHandler{
+ DB: database,
+ OAuthStore: nil,
+ Refresher: nil,
+ }
+
+ tests := []struct {
+ name string
+ confirmation string
+ }{
+ {"just DELETE", "DELETE"},
+ {"wrong handle", "DELETE wrong.handle"},
+ {"lowercase", "delete test.bsky.social"},
+ {"extra spaces", "DELETE test.bsky.social"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ reqBody := DeleteAccountRequest{
+ DeletePDSRecords: false,
+ Confirmation: tt.confirmation,
+ }
+ body, _ := json.Marshal(reqBody)
+ req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req = middleware.WithUser(req, testUser)
+
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Errorf("Expected status %d for confirmation %q, got %d", http.StatusBadRequest, tt.confirmation, rr.Code)
+ }
+ })
+ }
+}
+
+func TestDeleteAccountHandler_SuccessfulDeletion(t *testing.T) {
+ database := setupTestDB(t)
+ defer database.Close()
+
+ // Create test user with some data
+ testUser := &db.User{
+ DID: "did:plc:test123",
+ Handle: "test.bsky.social",
+ PDSEndpoint: "https://bsky.social",
+ LastSeen: time.Now(),
+ }
+ if err := db.UpsertUser(database, testUser); err != nil {
+ t.Fatalf("Failed to create user: %v", err)
+ }
+
+ // Create some manifests for the user
+ _, err := database.Exec(`
+ INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2,
+ "application/vnd.oci.image.manifest.v1+json", time.Now())
+ if err != nil {
+ t.Fatalf("Failed to create manifest: %v", err)
+ }
+
+ // Create OAuth store for testing
+ oauthStore := db.NewOAuthStore(database)
+
+ handler := &DeleteAccountHandler{
+ DB: database,
+ OAuthStore: oauthStore,
+ Refresher: nil, // No remote operations in this test
+ }
+
+ reqBody := DeleteAccountRequest{
+ DeletePDSRecords: false,
+ Confirmation: "DELETE test.bsky.social",
+ }
+ body, _ := json.Marshal(reqBody)
+ req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req = middleware.WithUser(req, testUser)
+
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String())
+ }
+
+ var response DeleteAccountResponse
+ if err := json.NewDecoder(rr.Body).Decode(&response); err != nil {
+ t.Fatalf("Failed to decode response: %v", err)
+ }
+
+ if !response.Success {
+ t.Error("Expected success=true")
+ }
+ if !response.AppViewDeleted {
+ t.Error("Expected appview_deleted=true")
+ }
+
+ // Verify user was actually deleted
+ var count int
+ err = database.QueryRow("SELECT COUNT(*) FROM users WHERE did = ?", testUser.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query user: %v", err)
+ }
+ if count != 0 {
+ t.Error("Expected user to be deleted from database")
+ }
+
+ // Verify manifests were cascade deleted
+ err = database.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ?", testUser.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query manifests: %v", err)
+ }
+ if count != 0 {
+ t.Error("Expected manifests to be cascade deleted")
+ }
+}
+
+func TestDeleteAccountHandler_InvalidJSON(t *testing.T) {
+ database := setupTestDB(t)
+ defer database.Close()
+
+ testUser := &db.User{
+ DID: "did:plc:test123",
+ Handle: "test.bsky.social",
+ PDSEndpoint: "https://bsky.social",
+ LastSeen: time.Now(),
+ }
+ if err := db.UpsertUser(database, testUser); err != nil {
+ t.Fatalf("Failed to create user: %v", err)
+ }
+
+ handler := &DeleteAccountHandler{
+ DB: database,
+ OAuthStore: nil,
+ Refresher: nil,
+ }
+
+ req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader([]byte("not json")))
+ req.Header.Set("Content-Type", "application/json")
+ req = middleware.WithUser(req, testUser)
+
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code)
+ }
+}
+
+func TestDeleteAccountHandler_DeletesHoldMembershipData(t *testing.T) {
+ database := setupTestDB(t)
+ defer database.Close()
+
+ testUser := &db.User{
+ DID: "did:plc:test123",
+ Handle: "test.bsky.social",
+ PDSEndpoint: "https://bsky.social",
+ LastSeen: time.Now(),
+ }
+ if err := db.UpsertUser(database, testUser); err != nil {
+ t.Fatalf("Failed to create user: %v", err)
+ }
+
+ // Create hold membership data (these tables don't cascade)
+ _, err := database.Exec(`
+ INSERT INTO hold_crew_approvals (hold_did, user_did, approved_at, expires_at)
+ VALUES (?, ?, ?, ?)
+ `, "did:web:hold.example.com", testUser.DID, time.Now(), time.Now().Add(24*time.Hour))
+ if err != nil {
+ t.Fatalf("Failed to create crew approval: %v", err)
+ }
+
+ _, err = database.Exec(`
+ INSERT INTO hold_crew_members (hold_did, member_did, rkey, permissions)
+ VALUES (?, ?, ?, ?)
+ `, "did:web:hold.example.com", testUser.DID, "member1", `["blob:read","blob:write"]`)
+ if err != nil {
+ t.Fatalf("Failed to create crew member: %v", err)
+ }
+
+ oauthStore := db.NewOAuthStore(database)
+
+ handler := &DeleteAccountHandler{
+ DB: database,
+ OAuthStore: oauthStore,
+ Refresher: nil,
+ }
+
+ reqBody := DeleteAccountRequest{
+ DeletePDSRecords: false,
+ Confirmation: "DELETE test.bsky.social",
+ }
+ body, _ := json.Marshal(reqBody)
+ req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req = middleware.WithUser(req, testUser)
+
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code)
+ }
+
+ // Verify hold membership data was deleted
+ var count int
+ err = database.QueryRow("SELECT COUNT(*) FROM hold_crew_approvals WHERE user_did = ?", testUser.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query crew approvals: %v", err)
+ }
+ if count != 0 {
+ t.Error("Expected crew approvals to be deleted")
+ }
+
+ err = database.QueryRow("SELECT COUNT(*) FROM hold_crew_members WHERE member_did = ?", testUser.DID).Scan(&count)
+ if err != nil {
+ t.Fatalf("Failed to query crew members: %v", err)
+ }
+ if count != 0 {
+ t.Error("Expected crew members to be deleted")
+ }
+}
diff --git a/pkg/appview/middleware/auth.go b/pkg/appview/middleware/auth.go
index 786624a..573781e 100644
--- a/pkg/appview/middleware/auth.go
+++ b/pkg/appview/middleware/auth.go
@@ -103,3 +103,10 @@ func GetUser(r *http.Request) *db.User {
}
return user
}
+
+// WithUser returns a new request with the user set in the context.
+// This is primarily useful for testing.
+func WithUser(r *http.Request, user *db.User) *http.Request {
+ ctx := context.WithValue(r.Context(), userKey, user)
+ return r.WithContext(ctx)
+}
diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go
index b99de0e..7495c4b 100644
--- a/pkg/appview/routes/routes.go
+++ b/pkg/appview/routes/routes.go
@@ -246,6 +246,13 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
DB: deps.Database,
Refresher: deps.Refresher,
}).ServeHTTP)
+
+ // GDPR account deletion
+ r.Delete("/api/account", (&uihandlers.DeleteAccountHandler{
+ DB: deps.Database,
+ OAuthStore: deps.OAuthStore,
+ Refresher: deps.Refresher,
+ }).ServeHTTP)
})
// Logout endpoint (supports both GET and POST)
diff --git a/pkg/appview/templates/pages/settings.html b/pkg/appview/templates/pages/settings.html
index b8c5538..f58f77c 100644
--- a/pkg/appview/templates/pages/settings.html
+++ b/pkg/appview/templates/pages/settings.html
@@ -194,6 +194,32 @@
+
+
+
+
Danger Zone
+
+
+
Delete Account
+
Permanently delete your ATCR account and all associated data. This action cannot be undone.
+
+
+
+
+ This will remove manifests, tags, stars, and profile data from your Bluesky account.
+ Your PDS data is always under your control, so this is optional.
+
+