feat: add account deletion with explicit confirmation (#363)

* feat: add account deletion with explicit confirmation

users can now permanently delete all their data from plyr.fm:
- DELETE /account/ endpoint with handle confirmation
- deletes tracks, albums, likes, comments, preferences, sessions, queue
- deletes R2 objects (audio files, images)
- optional: delete ATProto records from user's PDS with separate consent
- clear warning about orphaned references when deleting ATProto records

docs: updated offboarding.md with full account deletion documentation
tests: comprehensive regression tests for all deletion scenarios

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: correct test model fields and use client.request for DELETE with body

- use client.request("DELETE", ..., json=...) since AsyncClient.delete() doesn't support json=
- use oauth_session_data (string) not oauth_data (bytes) for UserSession
- use state (dict) not track_ids/current_index for QueueState

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add pdsls.dev link for self-service PDS management

users can now click through to pdsls.dev to manage their ATProto
records directly, or let us clean them up via the checkbox option.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 89c80e18 0b63df26

Changed files
+1072 -6
backend
docs
frontend
src
routes
portal
+2
backend/src/backend/api/__init__.py
··· 1 1 """api routers.""" 2 2 3 + from backend.api.account import router as account_router 3 4 from backend.api.artists import router as artists_router 4 5 from backend.api.audio import router as audio_router 5 6 from backend.api.auth import router as auth_router ··· 11 12 from backend.api.tracks import router as tracks_router 12 13 13 14 __all__ = [ 15 + "account_router", 14 16 "artists_router", 15 17 "audio_router", 16 18 "auth_router",
+212
backend/src/backend/api/account.py
··· 1 + """account management endpoints.""" 2 + 3 + import logging 4 + from typing import Annotated 5 + 6 + from fastapi import APIRouter, Depends, HTTPException 7 + from pydantic import BaseModel 8 + from sqlalchemy import delete, select 9 + from sqlalchemy.ext.asyncio import AsyncSession 10 + 11 + from backend._internal import Session, require_auth 12 + from backend._internal.atproto import delete_record_by_uri 13 + from backend.models import ( 14 + Album, 15 + Job, 16 + QueueState, 17 + Track, 18 + TrackComment, 19 + TrackLike, 20 + UserPreferences, 21 + UserSession, 22 + get_db, 23 + ) 24 + from backend.storage import storage 25 + 26 + logger = logging.getLogger(__name__) 27 + 28 + router = APIRouter(prefix="/account", tags=["account"]) 29 + 30 + 31 + class AccountDeleteRequest(BaseModel): 32 + """request body for account deletion.""" 33 + 34 + confirmation: str 35 + delete_atproto_records: bool = False 36 + 37 + 38 + class AccountDeleteResponse(BaseModel): 39 + """response body for account deletion.""" 40 + 41 + deleted: dict[str, int] 42 + 43 + 44 + @router.delete("/") 45 + async def delete_account( 46 + request: AccountDeleteRequest, 47 + db: Annotated[AsyncSession, Depends(get_db)], 48 + session: Session = Depends(require_auth), 49 + ) -> AccountDeleteResponse: 50 + """permanently delete user account and all associated data. 51 + 52 + this deletes: 53 + - all tracks (audio files and cover images from R2) 54 + - all albums (cover images from R2) 55 + - all likes given by the user 56 + - all comments made by the user 57 + - user preferences 58 + - all sessions 59 + - queue state 60 + - jobs 61 + 62 + optionally deletes ATProto records from user's PDS if requested. 63 + """ 64 + # verify confirmation matches user's handle 65 + if request.confirmation != session.handle: 66 + raise HTTPException( 67 + status_code=400, 68 + detail=f"confirmation must match your handle: {session.handle}", 69 + ) 70 + 71 + deleted_counts: dict[str, int] = { 72 + "tracks": 0, 73 + "albums": 0, 74 + "likes": 0, 75 + "comments": 0, 76 + "r2_objects": 0, 77 + "atproto_records": 0, 78 + } 79 + 80 + # collect ATProto URIs before deleting database records 81 + atproto_uris: list[str] = [] 82 + 83 + if request.delete_atproto_records: 84 + # track record URIs 85 + tracks_result = await db.execute( 86 + select(Track.atproto_record_uri).where( 87 + Track.artist_did == session.did, 88 + Track.atproto_record_uri.isnot(None), 89 + ) 90 + ) 91 + atproto_uris.extend(uri for (uri,) in tracks_result.fetchall()) 92 + 93 + # like record URIs (likes given by user) 94 + likes_result = await db.execute( 95 + select(TrackLike.atproto_like_uri).where(TrackLike.user_did == session.did) 96 + ) 97 + atproto_uris.extend(uri for (uri,) in likes_result.fetchall()) 98 + 99 + # comment record URIs (comments made by user) 100 + comments_result = await db.execute( 101 + select(TrackComment.atproto_comment_uri).where( 102 + TrackComment.user_did == session.did 103 + ) 104 + ) 105 + atproto_uris.extend(uri for (uri,) in comments_result.fetchall()) 106 + 107 + # collect R2 file IDs before deleting database records 108 + r2_audio_files: list[tuple[str, str]] = [] # (file_id, file_type) 109 + r2_image_ids: list[str] = [] 110 + 111 + # track audio and images 112 + tracks_result = await db.execute( 113 + select(Track.file_id, Track.file_type, Track.image_id).where( 114 + Track.artist_did == session.did 115 + ) 116 + ) 117 + for file_id, file_type, image_id in tracks_result.fetchall(): 118 + r2_audio_files.append((file_id, file_type)) 119 + if image_id: 120 + r2_image_ids.append(image_id) 121 + 122 + # album images 123 + albums_result = await db.execute( 124 + select(Album.image_id).where( 125 + Album.artist_did == session.did, Album.image_id.isnot(None) 126 + ) 127 + ) 128 + r2_image_ids.extend(image_id for (image_id,) in albums_result.fetchall()) 129 + 130 + # get track IDs for cascade deletion of likes/comments received 131 + track_ids_result = await db.execute( 132 + select(Track.id).where(Track.artist_did == session.did) 133 + ) 134 + track_ids = [tid for (tid,) in track_ids_result.fetchall()] 135 + 136 + # delete database records in dependency order 137 + 138 + # 1. delete comments made by user (on any track) 139 + result = await db.execute( 140 + delete(TrackComment).where(TrackComment.user_did == session.did) 141 + ) 142 + deleted_counts["comments"] = result.rowcount or 0 # type: ignore[union-attr] 143 + 144 + # 2. delete likes given by user (on any track) 145 + result = await db.execute( 146 + delete(TrackLike).where(TrackLike.user_did == session.did) 147 + ) 148 + deleted_counts["likes"] = result.rowcount or 0 # type: ignore[union-attr] 149 + 150 + # 3. delete comments ON user's tracks (from other users) 151 + # these are cascade deleted when tracks are deleted, but explicit is clearer 152 + if track_ids: 153 + await db.execute( 154 + delete(TrackComment).where(TrackComment.track_id.in_(track_ids)) 155 + ) 156 + 157 + # 4. delete likes ON user's tracks (from other users) 158 + if track_ids: 159 + await db.execute(delete(TrackLike).where(TrackLike.track_id.in_(track_ids))) 160 + 161 + # 5. delete tracks 162 + result = await db.execute(delete(Track).where(Track.artist_did == session.did)) 163 + deleted_counts["tracks"] = result.rowcount or 0 # type: ignore[union-attr] 164 + 165 + # 6. delete albums 166 + result = await db.execute(delete(Album).where(Album.artist_did == session.did)) 167 + deleted_counts["albums"] = result.rowcount or 0 # type: ignore[union-attr] 168 + 169 + # 7. delete queue state 170 + await db.execute(delete(QueueState).where(QueueState.did == session.did)) 171 + 172 + # 8. delete preferences 173 + await db.execute(delete(UserPreferences).where(UserPreferences.did == session.did)) 174 + 175 + # 9. delete jobs 176 + await db.execute(delete(Job).where(Job.owner_did == session.did)) 177 + 178 + # 10. delete all sessions (will log user out) 179 + await db.execute(delete(UserSession).where(UserSession.did == session.did)) 180 + 181 + await db.commit() 182 + 183 + # delete R2 objects (after database commit so refcount is 0) 184 + for file_id, file_type in r2_audio_files: 185 + try: 186 + if await storage.delete(file_id, file_type=file_type): 187 + deleted_counts["r2_objects"] += 1 188 + except Exception as e: 189 + logger.warning(f"failed to delete audio {file_id}: {e}") 190 + 191 + for image_id in r2_image_ids: 192 + try: 193 + if await storage.delete(image_id): 194 + deleted_counts["r2_objects"] += 1 195 + except Exception as e: 196 + logger.warning(f"failed to delete image {image_id}: {e}") 197 + 198 + # delete ATProto records if requested 199 + if request.delete_atproto_records and atproto_uris: 200 + for uri in atproto_uris: 201 + try: 202 + await delete_record_by_uri(session, uri) 203 + deleted_counts["atproto_records"] += 1 204 + except Exception as e: 205 + logger.warning(f"failed to delete ATProto record {uri}: {e}") 206 + 207 + logger.info( 208 + f"account deleted: {session.handle} ({session.did})", 209 + extra={"deleted": deleted_counts}, 210 + ) 211 + 212 + return AccountDeleteResponse(deleted=deleted_counts)
+2
backend/src/backend/main.py
··· 22 22 23 23 from backend._internal import notification_service, queue_service 24 24 from backend.api import ( 25 + account_router, 25 26 artists_router, 26 27 audio_router, 27 28 auth_router, ··· 149 150 150 151 # include routers 151 152 app.include_router(auth_router) 153 + app.include_router(account_router) 152 154 app.include_router(artists_router) 153 155 app.include_router(tracks_router) 154 156 app.include_router(albums_router)
+479
backend/tests/api/test_account_deletion.py
··· 1 + """tests for account deletion functionality.""" 2 + 3 + import json 4 + from collections.abc import Generator 5 + from unittest.mock import AsyncMock, patch 6 + 7 + import pytest 8 + from fastapi import FastAPI 9 + from httpx import ASGITransport, AsyncClient 10 + from sqlalchemy import select 11 + from sqlalchemy.ext.asyncio import AsyncSession 12 + 13 + from backend._internal import Session, require_auth 14 + from backend.main import app 15 + from backend.models import ( 16 + Album, 17 + Artist, 18 + QueueState, 19 + Track, 20 + TrackComment, 21 + TrackLike, 22 + UserPreferences, 23 + UserSession, 24 + ) 25 + 26 + 27 + class MockSession(Session): 28 + """mock session for auth bypass in tests.""" 29 + 30 + def __init__( 31 + self, did: str = "did:test:user123", handle: str = "testuser.bsky.social" 32 + ): 33 + self.did = did 34 + self.handle = handle 35 + self.session_id = "test_session_id" 36 + self.access_token = "test_token" 37 + self.refresh_token = "test_refresh" 38 + self.oauth_session = { 39 + "did": did, 40 + "handle": handle, 41 + "pds_url": "https://test.pds", 42 + "authserver_iss": "https://auth.test", 43 + "scope": "atproto transition:generic", 44 + "access_token": "test_token", 45 + "refresh_token": "test_refresh", 46 + "dpop_private_key_pem": "fake_key", 47 + "dpop_authserver_nonce": "", 48 + "dpop_pds_nonce": "", 49 + } 50 + 51 + 52 + TEST_DID = "did:plc:testuser123" 53 + TEST_HANDLE = "testuser.bsky.social" 54 + 55 + 56 + @pytest.fixture 57 + async def test_artist(db_session: AsyncSession) -> Artist: 58 + """create a test artist.""" 59 + artist = Artist( 60 + did=TEST_DID, 61 + handle=TEST_HANDLE, 62 + display_name="Test User", 63 + ) 64 + db_session.add(artist) 65 + await db_session.commit() 66 + await db_session.refresh(artist) 67 + return artist 68 + 69 + 70 + @pytest.fixture 71 + def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 72 + """create test app with mocked auth.""" 73 + 74 + async def mock_require_auth() -> Session: 75 + return MockSession(did=TEST_DID, handle=TEST_HANDLE) 76 + 77 + app.dependency_overrides[require_auth] = mock_require_auth 78 + 79 + yield app 80 + 81 + app.dependency_overrides.clear() 82 + 83 + 84 + async def _delete_account( 85 + client: AsyncClient, confirmation: str, delete_atproto: bool = False 86 + ): 87 + """helper to make DELETE request with JSON body.""" 88 + return await client.request( 89 + "DELETE", 90 + "/account/", 91 + json={"confirmation": confirmation, "delete_atproto_records": delete_atproto}, 92 + ) 93 + 94 + 95 + async def test_delete_account_requires_confirmation( 96 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 97 + ): 98 + """test that account deletion requires matching handle confirmation.""" 99 + async with AsyncClient( 100 + transport=ASGITransport(app=test_app), base_url="http://test" 101 + ) as client: 102 + response = await _delete_account(client, "wrong.handle") 103 + 104 + assert response.status_code == 400 105 + assert "confirmation must match your handle" in response.json()["detail"] 106 + 107 + 108 + async def test_delete_account_deletes_tracks( 109 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 110 + ): 111 + """test that account deletion removes all user tracks.""" 112 + track1 = Track( 113 + title="track 1", 114 + artist_did=TEST_DID, 115 + file_id="file1", 116 + file_type="mp3", 117 + extra={}, 118 + ) 119 + track2 = Track( 120 + title="track 2", 121 + artist_did=TEST_DID, 122 + file_id="file2", 123 + file_type="mp3", 124 + extra={}, 125 + ) 126 + db_session.add_all([track1, track2]) 127 + await db_session.commit() 128 + 129 + result = await db_session.execute(select(Track).where(Track.artist_did == TEST_DID)) 130 + assert len(result.scalars().all()) == 2 131 + 132 + with patch("backend.api.account.storage.delete", new_callable=AsyncMock): 133 + async with AsyncClient( 134 + transport=ASGITransport(app=test_app), base_url="http://test" 135 + ) as client: 136 + response = await _delete_account(client, TEST_HANDLE) 137 + 138 + assert response.status_code == 200 139 + assert response.json()["deleted"]["tracks"] == 2 140 + 141 + result = await db_session.execute(select(Track).where(Track.artist_did == TEST_DID)) 142 + assert len(result.scalars().all()) == 0 143 + 144 + 145 + async def test_delete_account_deletes_albums( 146 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 147 + ): 148 + """test that account deletion removes all user albums.""" 149 + album = Album( 150 + artist_did=TEST_DID, 151 + slug="test-album", 152 + title="Test Album", 153 + ) 154 + db_session.add(album) 155 + await db_session.commit() 156 + 157 + with patch("backend.api.account.storage.delete", new_callable=AsyncMock): 158 + async with AsyncClient( 159 + transport=ASGITransport(app=test_app), base_url="http://test" 160 + ) as client: 161 + response = await _delete_account(client, TEST_HANDLE) 162 + 163 + assert response.status_code == 200 164 + assert response.json()["deleted"]["albums"] == 1 165 + 166 + result = await db_session.execute(select(Album).where(Album.artist_did == TEST_DID)) 167 + assert result.scalar_one_or_none() is None 168 + 169 + 170 + async def test_delete_account_deletes_likes_given( 171 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 172 + ): 173 + """test that account deletion removes likes given by the user.""" 174 + other_artist = Artist( 175 + did="did:plc:other", 176 + handle="other.bsky.social", 177 + display_name="Other Artist", 178 + ) 179 + db_session.add(other_artist) 180 + await db_session.flush() 181 + 182 + other_track = Track( 183 + title="other track", 184 + artist_did=other_artist.did, 185 + file_id="other_file", 186 + file_type="mp3", 187 + extra={}, 188 + ) 189 + db_session.add(other_track) 190 + await db_session.flush() 191 + 192 + like = TrackLike( 193 + track_id=other_track.id, 194 + user_did=TEST_DID, 195 + atproto_like_uri="at://did:plc:testuser123/fm.plyr.like/abc", 196 + ) 197 + db_session.add(like) 198 + await db_session.commit() 199 + 200 + with patch("backend.api.account.storage.delete", new_callable=AsyncMock): 201 + async with AsyncClient( 202 + transport=ASGITransport(app=test_app), base_url="http://test" 203 + ) as client: 204 + response = await _delete_account(client, TEST_HANDLE) 205 + 206 + assert response.status_code == 200 207 + assert response.json()["deleted"]["likes"] == 1 208 + 209 + result = await db_session.execute( 210 + select(TrackLike).where(TrackLike.user_did == TEST_DID) 211 + ) 212 + assert result.scalar_one_or_none() is None 213 + 214 + 215 + async def test_delete_account_deletes_comments_made( 216 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 217 + ): 218 + """test that account deletion removes comments made by the user.""" 219 + other_artist = Artist( 220 + did="did:plc:other", 221 + handle="other.bsky.social", 222 + display_name="Other Artist", 223 + ) 224 + db_session.add(other_artist) 225 + await db_session.flush() 226 + 227 + other_track = Track( 228 + title="other track", 229 + artist_did=other_artist.did, 230 + file_id="other_file", 231 + file_type="mp3", 232 + extra={}, 233 + ) 234 + db_session.add(other_track) 235 + await db_session.flush() 236 + 237 + comment = TrackComment( 238 + track_id=other_track.id, 239 + user_did=TEST_DID, 240 + text="nice track!", 241 + timestamp_ms=30000, 242 + atproto_comment_uri="at://did:plc:testuser123/fm.plyr.comment/xyz", 243 + ) 244 + db_session.add(comment) 245 + await db_session.commit() 246 + 247 + with patch("backend.api.account.storage.delete", new_callable=AsyncMock): 248 + async with AsyncClient( 249 + transport=ASGITransport(app=test_app), base_url="http://test" 250 + ) as client: 251 + response = await _delete_account(client, TEST_HANDLE) 252 + 253 + assert response.status_code == 200 254 + assert response.json()["deleted"]["comments"] == 1 255 + 256 + result = await db_session.execute( 257 + select(TrackComment).where(TrackComment.user_did == TEST_DID) 258 + ) 259 + assert result.scalar_one_or_none() is None 260 + 261 + 262 + async def test_delete_account_deletes_preferences( 263 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 264 + ): 265 + """test that account deletion removes user preferences.""" 266 + prefs = UserPreferences( 267 + did=TEST_DID, 268 + accent_color="#ff0000", 269 + auto_advance=True, 270 + allow_comments=True, 271 + ) 272 + db_session.add(prefs) 273 + await db_session.commit() 274 + 275 + with patch("backend.api.account.storage.delete", new_callable=AsyncMock): 276 + async with AsyncClient( 277 + transport=ASGITransport(app=test_app), base_url="http://test" 278 + ) as client: 279 + response = await _delete_account(client, TEST_HANDLE) 280 + 281 + assert response.status_code == 200 282 + 283 + result = await db_session.execute( 284 + select(UserPreferences).where(UserPreferences.did == TEST_DID) 285 + ) 286 + assert result.scalar_one_or_none() is None 287 + 288 + 289 + async def test_delete_account_deletes_sessions( 290 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 291 + ): 292 + """test that account deletion removes all user sessions.""" 293 + session = UserSession( 294 + did=TEST_DID, 295 + handle=TEST_HANDLE, 296 + session_id="session123", 297 + oauth_session_data=json.dumps({"token": "fake"}), 298 + ) 299 + db_session.add(session) 300 + await db_session.commit() 301 + 302 + with patch("backend.api.account.storage.delete", new_callable=AsyncMock): 303 + async with AsyncClient( 304 + transport=ASGITransport(app=test_app), base_url="http://test" 305 + ) as client: 306 + response = await _delete_account(client, TEST_HANDLE) 307 + 308 + assert response.status_code == 200 309 + 310 + result = await db_session.execute( 311 + select(UserSession).where(UserSession.did == TEST_DID) 312 + ) 313 + assert result.scalar_one_or_none() is None 314 + 315 + 316 + async def test_delete_account_deletes_queue( 317 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 318 + ): 319 + """test that account deletion removes user queue state.""" 320 + queue = QueueState( 321 + did=TEST_DID, 322 + state={"track_ids": [1, 2, 3], "current_index": 0}, 323 + ) 324 + db_session.add(queue) 325 + await db_session.commit() 326 + 327 + with patch("backend.api.account.storage.delete", new_callable=AsyncMock): 328 + async with AsyncClient( 329 + transport=ASGITransport(app=test_app), base_url="http://test" 330 + ) as client: 331 + response = await _delete_account(client, TEST_HANDLE) 332 + 333 + assert response.status_code == 200 334 + 335 + result = await db_session.execute( 336 + select(QueueState).where(QueueState.did == TEST_DID) 337 + ) 338 + assert result.scalar_one_or_none() is None 339 + 340 + 341 + async def test_delete_account_with_atproto_records( 342 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 343 + ): 344 + """test that account deletion can delete ATProto records when requested.""" 345 + track = Track( 346 + title="track with atproto", 347 + artist_did=TEST_DID, 348 + file_id="file_atproto", 349 + file_type="mp3", 350 + extra={}, 351 + atproto_record_uri="at://did:plc:testuser123/fm.plyr.track/abc", 352 + ) 353 + db_session.add(track) 354 + await db_session.commit() 355 + 356 + with ( 357 + patch("backend.api.account.storage.delete", new_callable=AsyncMock), 358 + patch( 359 + "backend.api.account.delete_record_by_uri", new_callable=AsyncMock 360 + ) as mock_delete_atproto, 361 + ): 362 + async with AsyncClient( 363 + transport=ASGITransport(app=test_app), base_url="http://test" 364 + ) as client: 365 + response = await _delete_account(client, TEST_HANDLE, delete_atproto=True) 366 + 367 + assert response.status_code == 200 368 + assert response.json()["deleted"]["atproto_records"] == 1 369 + 370 + mock_delete_atproto.assert_called_once() 371 + 372 + 373 + async def test_delete_account_deletes_r2_objects( 374 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 375 + ): 376 + """test that account deletion removes R2 objects.""" 377 + track = Track( 378 + title="track with media", 379 + artist_did=TEST_DID, 380 + file_id="audio_file", 381 + file_type="mp3", 382 + extra={}, 383 + image_id="image_file", 384 + ) 385 + db_session.add(track) 386 + await db_session.commit() 387 + 388 + delete_calls: list[tuple[str, str | None]] = [] 389 + 390 + async def mock_delete(file_id: str, file_type: str | None = None) -> bool: 391 + delete_calls.append((file_id, file_type)) 392 + return True 393 + 394 + with patch("backend.api.account.storage.delete", side_effect=mock_delete): 395 + async with AsyncClient( 396 + transport=ASGITransport(app=test_app), base_url="http://test" 397 + ) as client: 398 + response = await _delete_account(client, TEST_HANDLE) 399 + 400 + assert response.status_code == 200 401 + assert response.json()["deleted"]["r2_objects"] == 2 402 + 403 + deleted_ids = [call[0] for call in delete_calls] 404 + assert "audio_file" in deleted_ids 405 + assert "image_file" in deleted_ids 406 + 407 + 408 + async def test_delete_account_deletes_likes_on_user_tracks( 409 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 410 + ): 411 + """test that account deletion removes likes from OTHER users on the user's tracks.""" 412 + track = Track( 413 + title="my track", 414 + artist_did=TEST_DID, 415 + file_id="my_file", 416 + file_type="mp3", 417 + extra={}, 418 + ) 419 + db_session.add(track) 420 + await db_session.flush() 421 + 422 + other_like = TrackLike( 423 + track_id=track.id, 424 + user_did="did:plc:other", 425 + atproto_like_uri="at://did:plc:other/fm.plyr.like/xyz", 426 + ) 427 + db_session.add(other_like) 428 + await db_session.commit() 429 + 430 + with patch("backend.api.account.storage.delete", new_callable=AsyncMock): 431 + async with AsyncClient( 432 + transport=ASGITransport(app=test_app), base_url="http://test" 433 + ) as client: 434 + response = await _delete_account(client, TEST_HANDLE) 435 + 436 + assert response.status_code == 200 437 + 438 + result = await db_session.execute( 439 + select(TrackLike).where(TrackLike.track_id == track.id) 440 + ) 441 + assert result.scalar_one_or_none() is None 442 + 443 + 444 + async def test_delete_account_deletes_comments_on_user_tracks( 445 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 446 + ): 447 + """test that account deletion removes comments from OTHER users on the user's tracks.""" 448 + track = Track( 449 + title="my track", 450 + artist_did=TEST_DID, 451 + file_id="my_file", 452 + file_type="mp3", 453 + extra={}, 454 + ) 455 + db_session.add(track) 456 + await db_session.flush() 457 + 458 + other_comment = TrackComment( 459 + track_id=track.id, 460 + user_did="did:plc:other", 461 + text="great track!", 462 + timestamp_ms=15000, 463 + atproto_comment_uri="at://did:plc:other/fm.plyr.comment/xyz", 464 + ) 465 + db_session.add(other_comment) 466 + await db_session.commit() 467 + 468 + with patch("backend.api.account.storage.delete", new_callable=AsyncMock): 469 + async with AsyncClient( 470 + transport=ASGITransport(app=test_app), base_url="http://test" 471 + ) as client: 472 + response = await _delete_account(client, TEST_HANDLE) 473 + 474 + assert response.status_code == 200 475 + 476 + result = await db_session.execute( 477 + select(TrackComment).where(TrackComment.track_id == track.id) 478 + ) 479 + assert result.scalar_one_or_none() is None
+68 -6
docs/offboarding.md
··· 33 33 * This ensures we don't pay for indefinite storage of duplicate data. 34 34 * Users must download their export within this window. 35 35 36 - ## Account Deletion (Planned) 36 + ## Account Deletion 37 + 38 + Users can permanently delete their account and all associated data. This is a synchronous, interactive process. 39 + 40 + ### What Gets Deleted 41 + 42 + #### Always Deleted (plyr.fm infrastructure) 43 + 44 + | Location | Data | 45 + |----------|------| 46 + | **PostgreSQL** | tracks, albums, likes (given), comments (made), preferences, sessions, queue entries, jobs | 47 + | **R2 Storage** | audio files, track cover images, album cover images | 48 + 49 + #### Optionally Deleted (user's ATProto PDS) 50 + 51 + If the user opts in, we delete records from their Personal Data Server: 52 + 53 + | Collection | Description | 54 + |------------|-------------| 55 + | `fm.plyr.track` / `fm.plyr.dev.track` | track metadata records | 56 + | `fm.plyr.like` / `fm.plyr.dev.like` | like records | 57 + | `fm.plyr.comment` / `fm.plyr.dev.comment` | comment records | 37 58 38 - * **Goal**: Allow users to permanently delete their account and all associated data (tracks, images, metadata). 39 - * **Prerequisite**: Users should be encouraged to export their data before deletion. 40 - * **Implementation**: 41 - * Will likely use a similar `Job` based approach for reliability. 42 - * Must scrub database records and delete corresponding R2 objects. 59 + > **Note**: ATProto deletion requires a valid authenticated session. If the session has expired or lacks required scopes, ATProto records will remain on the user's PDS but all plyr.fm data will still be deleted. 60 + 61 + ### Workflow 62 + 63 + 1. **Confirmation**: User types their handle to confirm intent 64 + 2. **ATProto Option**: Checkbox to opt into deleting ATProto records 65 + 3. **Processing**: 66 + - Delete R2 objects (audio, images) 67 + - Delete database records in dependency order 68 + - If opted in: delete ATProto records via PDS API 69 + 4. **Session Cleanup**: All sessions invalidated, user logged out 70 + 71 + ### API 72 + 73 + ``` 74 + DELETE /account/ 75 + ``` 76 + 77 + **Request Body**: 78 + ```json 79 + { 80 + "confirmation": "handle.bsky.social", 81 + "delete_atproto_records": true 82 + } 83 + ``` 84 + 85 + **Response** (success): 86 + ```json 87 + { 88 + "deleted": { 89 + "tracks": 5, 90 + "albums": 1, 91 + "likes": 12, 92 + "comments": 3, 93 + "r2_objects": 11, 94 + "atproto_records": 20 95 + } 96 + } 97 + ``` 98 + 99 + ### Important Notes 100 + 101 + - **Irreversible**: There is no undo. Export data first if needed. 102 + - **Likes received**: Likes from other users on your tracks are deleted when your tracks are deleted. 103 + - **Comments received**: Comments from other users on your tracks are deleted when your tracks are deleted. 104 + - **ATProto propagation**: Even after deletion from your PDS, cached copies may exist on relay servers temporarily.
+309
frontend/src/routes/portal/+page.svelte
··· 65 65 // export state 66 66 let exportingMedia = $state(false); 67 67 68 + // account deletion state 69 + let showDeleteConfirm = $state(false); 70 + let deleteConfirmText = $state(''); 71 + let deleteAtprotoRecords = $state(false); 72 + let deleting = $state(false); 73 + 68 74 onMount(async () => { 69 75 // check if exchange_token is in URL (from OAuth callback) 70 76 const params = new URLSearchParams(window.location.search); ··· 547 553 exportingMedia = false; 548 554 } 549 555 } 556 + 557 + async function deleteAccount() { 558 + if (!auth.user || deleteConfirmText !== auth.user.handle) return; 559 + 560 + deleting = true; 561 + const toastId = toast.info('deleting account...', 0); 562 + 563 + try { 564 + const response = await fetch(`${API_URL}/account/`, { 565 + method: 'DELETE', 566 + headers: { 'Content-Type': 'application/json' }, 567 + credentials: 'include', 568 + body: JSON.stringify({ 569 + confirmation: deleteConfirmText, 570 + delete_atproto_records: deleteAtprotoRecords 571 + }) 572 + }); 573 + 574 + if (!response.ok) { 575 + const error = await response.json(); 576 + toast.dismiss(toastId); 577 + toast.error(error.detail || 'failed to delete account'); 578 + deleting = false; 579 + return; 580 + } 581 + 582 + const result = await response.json(); 583 + toast.dismiss(toastId); 584 + 585 + // show summary of what was deleted 586 + const { deleted } = result; 587 + const summary = [ 588 + deleted.tracks && `${deleted.tracks} tracks`, 589 + deleted.albums && `${deleted.albums} albums`, 590 + deleted.likes && `${deleted.likes} likes`, 591 + deleted.comments && `${deleted.comments} comments`, 592 + deleted.atproto_records && `${deleted.atproto_records} ATProto records` 593 + ].filter(Boolean).join(', '); 594 + 595 + toast.success(`account deleted: ${summary || 'all data removed'}`); 596 + 597 + // redirect to home after a moment 598 + setTimeout(() => { 599 + window.location.href = '/'; 600 + }, 2000); 601 + 602 + } catch (e) { 603 + console.error('delete failed:', e); 604 + toast.dismiss(toastId); 605 + toast.error('failed to delete account'); 606 + deleting = false; 607 + } 608 + } 550 609 </script> 551 610 552 611 {#if loading} ··· 992 1051 </button> 993 1052 </div> 994 1053 {/if} 1054 + 1055 + <div class="data-control danger-zone"> 1056 + <div class="control-info"> 1057 + <h3>delete account</h3> 1058 + <p class="control-description"> 1059 + permanently delete all your data from plyr.fm. 1060 + <a href="https://github.com/zzstoatzz/plyr.fm/blob/main/docs/offboarding.md#account-deletion" target="_blank" rel="noopener">learn more</a> 1061 + </p> 1062 + </div> 1063 + {#if !showDeleteConfirm} 1064 + <button 1065 + class="delete-account-btn" 1066 + onclick={() => showDeleteConfirm = true} 1067 + > 1068 + delete account 1069 + </button> 1070 + {:else} 1071 + <div class="delete-confirm-panel"> 1072 + <p class="delete-warning"> 1073 + this will permanently delete all your tracks, albums, likes, and comments from plyr.fm. this cannot be undone. 1074 + </p> 1075 + 1076 + <div class="atproto-section"> 1077 + <label class="atproto-option"> 1078 + <input 1079 + type="checkbox" 1080 + bind:checked={deleteAtprotoRecords} 1081 + /> 1082 + <span>also delete records from my ATProto repo</span> 1083 + </label> 1084 + <p class="atproto-note"> 1085 + you can manage your PDS records directly via <a href="https://pdsls.dev/at://{auth.user?.did}" target="_blank" rel="noopener">pdsls.dev</a>, or let us clean them up for you. 1086 + </p> 1087 + {#if deleteAtprotoRecords} 1088 + <p class="atproto-warning"> 1089 + this removes track, like, and comment records from your PDS. other users' likes and comments that reference your tracks will become orphaned (pointing to records that no longer exist). 1090 + </p> 1091 + {/if} 1092 + </div> 1093 + 1094 + <p class="confirm-prompt"> 1095 + type <strong>{auth.user?.handle}</strong> to confirm: 1096 + </p> 1097 + <input 1098 + type="text" 1099 + class="confirm-input" 1100 + bind:value={deleteConfirmText} 1101 + placeholder={auth.user?.handle} 1102 + disabled={deleting} 1103 + /> 1104 + 1105 + <div class="delete-actions"> 1106 + <button 1107 + class="cancel-btn" 1108 + onclick={() => { 1109 + showDeleteConfirm = false; 1110 + deleteConfirmText = ''; 1111 + deleteAtprotoRecords = false; 1112 + }} 1113 + disabled={deleting} 1114 + > 1115 + cancel 1116 + </button> 1117 + <button 1118 + class="confirm-delete-btn" 1119 + onclick={deleteAccount} 1120 + disabled={deleting || deleteConfirmText !== auth.user?.handle} 1121 + > 1122 + {deleting ? 'deleting...' : 'delete everything'} 1123 + </button> 1124 + </div> 1125 + </div> 1126 + {/if} 1127 + </div> 995 1128 </section> 996 1129 </main> 997 1130 {/if} ··· 1751 1884 opacity: 0.5; 1752 1885 cursor: not-allowed; 1753 1886 transform: none; 1887 + } 1888 + 1889 + /* danger zone / account deletion */ 1890 + .danger-zone { 1891 + border-color: #4a2020; 1892 + background: #1a1010; 1893 + flex-direction: column; 1894 + align-items: stretch; 1895 + } 1896 + 1897 + .danger-zone .control-info h3 { 1898 + color: #ff6b6b; 1899 + } 1900 + 1901 + .danger-zone .control-description a { 1902 + color: #888; 1903 + text-decoration: underline; 1904 + } 1905 + 1906 + .danger-zone .control-description a:hover { 1907 + color: #aaa; 1908 + } 1909 + 1910 + .delete-account-btn { 1911 + padding: 0.6rem 1.25rem; 1912 + background: transparent; 1913 + color: #ff6b6b; 1914 + border: 1px solid #ff6b6b; 1915 + border-radius: 6px; 1916 + font-size: 0.9rem; 1917 + font-weight: 600; 1918 + cursor: pointer; 1919 + transition: all 0.2s; 1920 + align-self: flex-end; 1921 + } 1922 + 1923 + .delete-account-btn:hover { 1924 + background: #ff6b6b; 1925 + color: white; 1926 + } 1927 + 1928 + .delete-confirm-panel { 1929 + margin-top: 1rem; 1930 + padding-top: 1rem; 1931 + border-top: 1px solid #3a2020; 1932 + } 1933 + 1934 + .delete-warning { 1935 + color: #ff8888; 1936 + font-size: 0.9rem; 1937 + margin: 0 0 1rem 0; 1938 + line-height: 1.5; 1939 + } 1940 + 1941 + .atproto-section { 1942 + margin-bottom: 1rem; 1943 + } 1944 + 1945 + .atproto-option { 1946 + display: flex; 1947 + align-items: center; 1948 + gap: 0.5rem; 1949 + font-size: 0.9rem; 1950 + color: #aaa; 1951 + cursor: pointer; 1952 + } 1953 + 1954 + .atproto-option input { 1955 + width: 16px; 1956 + height: 16px; 1957 + cursor: pointer; 1958 + } 1959 + 1960 + .atproto-note { 1961 + margin: 0.5rem 0 0 0; 1962 + font-size: 0.85rem; 1963 + color: #777; 1964 + } 1965 + 1966 + .atproto-note a { 1967 + color: #999; 1968 + text-decoration: underline; 1969 + } 1970 + 1971 + .atproto-note a:hover { 1972 + color: #bbb; 1973 + } 1974 + 1975 + .atproto-warning { 1976 + margin: 0.75rem 0 0 0; 1977 + padding: 0.75rem; 1978 + background: rgba(255, 107, 107, 0.1); 1979 + border-left: 2px solid #ff6b6b; 1980 + font-size: 0.85rem; 1981 + color: #cc8888; 1982 + line-height: 1.5; 1983 + } 1984 + 1985 + .confirm-prompt { 1986 + font-size: 0.9rem; 1987 + color: #888; 1988 + margin: 0 0 0.5rem 0; 1989 + } 1990 + 1991 + .confirm-prompt strong { 1992 + color: #e8e8e8; 1993 + font-family: monospace; 1994 + } 1995 + 1996 + .confirm-input { 1997 + width: 100%; 1998 + padding: 0.75rem; 1999 + background: #0a0505; 2000 + border: 1px solid #3a2020; 2001 + border-radius: 6px; 2002 + color: #e8e8e8; 2003 + font-size: 0.9rem; 2004 + font-family: monospace; 2005 + margin-bottom: 1rem; 2006 + } 2007 + 2008 + .confirm-input:focus { 2009 + outline: none; 2010 + border-color: #ff6b6b; 2011 + } 2012 + 2013 + .confirm-input::placeholder { 2014 + color: #555; 2015 + } 2016 + 2017 + .delete-actions { 2018 + display: flex; 2019 + gap: 0.75rem; 2020 + justify-content: flex-end; 2021 + } 2022 + 2023 + .cancel-btn { 2024 + padding: 0.6rem 1.25rem; 2025 + background: transparent; 2026 + color: #888; 2027 + border: 1px solid #444; 2028 + border-radius: 6px; 2029 + font-size: 0.9rem; 2030 + cursor: pointer; 2031 + transition: all 0.2s; 2032 + } 2033 + 2034 + .cancel-btn:hover:not(:disabled) { 2035 + border-color: #666; 2036 + color: #aaa; 2037 + } 2038 + 2039 + .cancel-btn:disabled { 2040 + opacity: 0.5; 2041 + cursor: not-allowed; 2042 + } 2043 + 2044 + .confirm-delete-btn { 2045 + padding: 0.6rem 1.25rem; 2046 + background: #ff4444; 2047 + color: white; 2048 + border: none; 2049 + border-radius: 6px; 2050 + font-size: 0.9rem; 2051 + font-weight: 600; 2052 + cursor: pointer; 2053 + transition: all 0.2s; 2054 + } 2055 + 2056 + .confirm-delete-btn:hover:not(:disabled) { 2057 + background: #ff2222; 2058 + } 2059 + 2060 + .confirm-delete-btn:disabled { 2061 + opacity: 0.5; 2062 + cursor: not-allowed; 1754 2063 } 1755 2064 1756 2065 .toggle-switch {