feat: developer tokens with independent OAuth grants (#367)

* feat: developer token management with listing and revocation

adds full lifecycle management for developer tokens:

**backend**
- add `is_developer_token` and `token_name` fields to UserSession model
- add GET /auth/developer-tokens endpoint to list user's tokens
- add DELETE /auth/developer-tokens/{prefix} to revoke tokens
- update POST /auth/developer-token to accept optional name parameter
- add AuthSettings config for token expiration limits
- fix connection pool leak in test fixtures (was causing TooManyConnectionsError)

**frontend**
- show existing tokens in portal with names, creation/expiration dates
- add token name input field when creating new tokens
- add revoke button for each active token
- add loading state indicator while fetching tokens

**tests**
- add 9 tests for developer token API (create, list, revoke)
- add integration test for upload/delete flow
- add `just backend integration` recipe

**docs**
- update authentication.md with token management section

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

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

* feat: add cyclopts CLI for upload/download/list/delete

- PEP 723 inline script deps (just uv run it)
- pydantic-settings for PLYR_TOKEN/PLYR_API_URL config
- rich tables and status spinners

* refactor: rename RelaySettingsSection to AppSettingsSection

relay is the old project name

* refactor: use separate OAuth grants for developer tokens

Developer tokens now get their own OAuth credentials via a fresh
authorization flow, rather than copying credentials from the browser
session. This prevents tokens from going stale when browser sessions
refresh their tokens independently.

Changes:
- Add PendingDevToken model to store OAuth flow metadata by state
- Add POST /auth/developer-token/start to initiate OAuth for dev tokens
- Modify /auth/callback to detect dev token flows and create independent sessions
- Update frontend to redirect to PDS authorization and handle callback
- Remove old credential-copying endpoint
- Rewrite tests for new OAuth-based flow

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

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

* docs: update authentication.md for OAuth-based dev tokens

- Update creating token flow to mention PDS authorization step
- Update API example to show /auth/developer-token/start endpoint
- Explain that tokens have independent OAuth grants, not inherited credentials
- Add PR #367 reference

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

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

* fix: default CLI to localhost, document env overrides

- Default PLYR_API_URL to http://localhost:8001 (local dev)
- Add docstring examples for staging and production overrides
- Prevents accidental production API calls during development

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

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

* fix: prevent dev token exchange from overwriting browser cookie

When creating a dev token via OAuth, the exchange endpoint was setting
a cookie with the dev token's session_id, overwriting the browser's
session cookie. This caused logout to delete the dev token instead of
the browser session.

Fix:
- Add is_dev_token flag to ExchangeToken model
- Pass is_dev_token=True when creating exchange token for dev tokens
- Skip cookie setting in /auth/exchange for dev token exchanges

Now dev tokens are fully independent of browser sessions.

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

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

* docs: update dev token section with CLI usage and cookie isolation

- Replace sandbox script reference with scripts/plyr.py CLI
- Add CLI examples for list/upload/download/delete
- Mention cookie isolation in how it works section

🤖 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 6a82cf4f 2171d311

+38
backend/alembic/versions/2025_11_28_003756_9851b6850eb1_add_developer_token_fields_to_sessions.py
··· 1 + """add developer token fields to sessions 2 + 3 + Revision ID: 9851b6850eb1 4 + Revises: e9acf24e6885 5 + Create Date: 2025-11-28 00:37:56.838421 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "9851b6850eb1" 17 + down_revision: str | Sequence[str] | None = "e9acf24e6885" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Upgrade schema.""" 24 + op.add_column( 25 + "user_sessions", 26 + sa.Column( 27 + "is_developer_token", sa.Boolean(), server_default="false", nullable=False 28 + ), 29 + ) 30 + op.add_column( 31 + "user_sessions", sa.Column("token_name", sa.String(length=100), nullable=True) 32 + ) 33 + 34 + 35 + def downgrade() -> None: 36 + """Downgrade schema.""" 37 + op.drop_column("user_sessions", "token_name") 38 + op.drop_column("user_sessions", "is_developer_token")
+58
backend/alembic/versions/2025_11_28_103800_e3d1b1eebe4b_add_pending_dev_tokens_table.py
··· 1 + """add pending_dev_tokens table and exchange_tokens.is_dev_token 2 + 3 + Revision ID: e3d1b1eebe4b 4 + Revises: 9851b6850eb1 5 + Create Date: 2025-11-28 10:38:00.882501 6 + """ 7 + 8 + from collections.abc import Sequence 9 + 10 + import sqlalchemy as sa 11 + 12 + from alembic import op 13 + 14 + revision: str = "e3d1b1eebe4b" 15 + down_revision: str | Sequence[str] | None = "9851b6850eb1" 16 + branch_labels: str | Sequence[str] | None = None 17 + depends_on: str | Sequence[str] | None = None 18 + 19 + 20 + def upgrade() -> None: 21 + """Create pending_dev_tokens table and add is_dev_token to exchange_tokens.""" 22 + op.create_table( 23 + "pending_dev_tokens", 24 + sa.Column("state", sa.String(64), primary_key=True, index=True), 25 + sa.Column("did", sa.String(256), nullable=False, index=True), 26 + sa.Column("token_name", sa.String(100), nullable=True), 27 + sa.Column("expires_in_days", sa.Integer(), nullable=False, server_default="90"), 28 + sa.Column( 29 + "created_at", 30 + sa.DateTime(timezone=True), 31 + nullable=False, 32 + server_default=sa.func.now(), 33 + index=True, 34 + ), 35 + sa.Column( 36 + "expires_at", 37 + sa.DateTime(timezone=True), 38 + nullable=False, 39 + server_default=sa.text("now() + interval '10 minutes'"), 40 + ), 41 + ) 42 + 43 + # add is_dev_token column to exchange_tokens to prevent cookie override 44 + op.add_column( 45 + "exchange_tokens", 46 + sa.Column( 47 + "is_dev_token", 48 + sa.Boolean(), 49 + nullable=False, 50 + server_default=sa.text("false"), 51 + ), 52 + ) 53 + 54 + 55 + def downgrade() -> None: 56 + """Drop pending_dev_tokens table and remove is_dev_token from exchange_tokens.""" 57 + op.drop_column("exchange_tokens", "is_dev_token") 58 + op.drop_table("pending_dev_tokens")
+4
backend/justfile
··· 12 12 uv run pytest {{ ARGS }} 13 13 docker compose -f tests/docker-compose.yml down 14 14 15 + # run integration tests (requires running backend and PLYRFM_API_TOKEN in .env) 16 + integration: 17 + set -a && source ../.env && set +a && uv run pytest tests/test_integration_upload.py -m integration -v -s 18 + 15 19 # run type checking and linting 16 20 lint: 17 21 uv run ty check
+14
backend/src/backend/_internal/__init__.py
··· 1 1 """internal relay modules.""" 2 2 3 3 from backend._internal.auth import ( 4 + DeveloperToken, 5 + PendingDevTokenData, 4 6 Session, 5 7 check_artist_profile_exists, 6 8 consume_exchange_token, 7 9 create_exchange_token, 8 10 create_session, 11 + delete_pending_dev_token, 9 12 delete_session, 13 + get_pending_dev_token, 10 14 get_session, 11 15 handle_oauth_callback, 16 + list_developer_tokens, 12 17 oauth_client, 13 18 require_artist_profile, 14 19 require_auth, 20 + revoke_developer_token, 21 + save_pending_dev_token, 15 22 start_oauth_flow, 16 23 update_session_tokens, 17 24 ) ··· 20 27 from backend._internal.queue import queue_service 21 28 22 29 __all__ = [ 30 + "DeveloperToken", 31 + "PendingDevTokenData", 23 32 "Session", 24 33 "check_artist_profile_exists", 25 34 "consume_exchange_token", 26 35 "create_exchange_token", 27 36 "create_session", 37 + "delete_pending_dev_token", 28 38 "delete_session", 29 39 "get_like_count_safe", 40 + "get_pending_dev_token", 30 41 "get_session", 31 42 "handle_oauth_callback", 43 + "list_developer_tokens", 32 44 "notification_service", 33 45 "oauth_client", 34 46 "queue_service", 35 47 "require_artist_profile", 36 48 "require_auth", 49 + "revoke_developer_token", 50 + "save_pending_dev_token", 37 51 "start_oauth_flow", 38 52 "update_session_tokens", 39 53 ]
+165 -9
backend/src/backend/_internal/auth.py
··· 15 15 16 16 from backend._internal.oauth_stores import PostgresStateStore 17 17 from backend.config import settings 18 - from backend.models import ExchangeToken, UserSession 18 + from backend.models import ExchangeToken, PendingDevToken, UserSession 19 19 from backend.utilities.database import db_session 20 20 21 21 logger = logging.getLogger(__name__) ··· 108 108 return None 109 109 110 110 111 - async def create_session(did: str, handle: str, oauth_session: dict[str, Any]) -> str: 112 - """create a new session for authenticated user with encrypted OAuth data.""" 111 + async def create_session( 112 + did: str, 113 + handle: str, 114 + oauth_session: dict[str, Any], 115 + expires_in_days: int = 14, 116 + is_developer_token: bool = False, 117 + token_name: str | None = None, 118 + ) -> str: 119 + """create a new session for authenticated user with encrypted OAuth data. 120 + 121 + args: 122 + did: user's decentralized identifier 123 + handle: user's ATProto handle 124 + oauth_session: OAuth session data to encrypt and store 125 + expires_in_days: session expiration in days (default 14, use 0 for no expiration) 126 + is_developer_token: whether this is a developer token (for listing/revocation) 127 + token_name: optional name for the token (only for developer tokens) 128 + """ 113 129 session_id = secrets.token_urlsafe(32) 114 130 115 131 # encrypt sensitive OAuth session data before storing 116 132 encrypted_data = _encrypt_data(json.dumps(oauth_session)) 117 133 118 - # store in database with expiration (2 weeks from now per OAuth 2.1 requirements) 119 - expires_at = datetime.now(UTC) + timedelta(days=14) 134 + # store in database with expiration 135 + expires_at = ( 136 + datetime.now(UTC) + timedelta(days=expires_in_days) 137 + if expires_in_days > 0 138 + else None 139 + ) 120 140 121 141 async with db_session() as db: 122 142 user_session = UserSession( ··· 125 145 handle=handle, 126 146 oauth_session_data=encrypted_data, 127 147 expires_at=expires_at, 148 + is_developer_token=is_developer_token, 149 + token_name=token_name, 128 150 ) 129 151 db.add(user_session) 130 152 await db.commit() ··· 252 274 return artist is not None 253 275 254 276 255 - async def create_exchange_token(session_id: str) -> str: 277 + async def create_exchange_token(session_id: str, is_dev_token: bool = False) -> str: 256 278 """create a one-time use exchange token for secure OAuth callback. 257 279 258 280 exchange tokens expire after 60 seconds and can only be used once, 259 281 preventing session_id exposure in browser history/referrers. 282 + 283 + args: 284 + session_id: the session to associate with this exchange token 285 + is_dev_token: if True, the exchange will not set a browser cookie 260 286 """ 261 287 token = secrets.token_urlsafe(32) 262 288 ··· 264 290 exchange_token = ExchangeToken( 265 291 token=token, 266 292 session_id=session_id, 293 + is_dev_token=is_dev_token, 267 294 ) 268 295 db.add(exchange_token) 269 296 await db.commit() ··· 271 298 return token 272 299 273 300 274 - async def consume_exchange_token(token: str) -> str | None: 275 - """consume an exchange token and return the associated session_id. 301 + async def consume_exchange_token(token: str) -> tuple[str, bool] | None: 302 + """consume an exchange token and return (session_id, is_dev_token). 276 303 277 304 returns None if token is invalid, expired, or already used. 278 305 uses atomic UPDATE to prevent race conditions (token can only be used once). ··· 293 320 if datetime.now(UTC) > exchange_token.expires_at: 294 321 return None 295 322 323 + # capture is_dev_token before atomic update 324 + is_dev_token = exchange_token.is_dev_token 325 + 296 326 # atomically mark as used ONLY if not already used 297 327 # this prevents race conditions where two requests try to use the same token 298 328 result = await db.execute( ··· 305 335 306 336 # if no rows were updated, token was already used 307 337 session_id = result.scalar_one_or_none() 308 - return session_id 338 + if session_id is None: 339 + return None 340 + 341 + return session_id, is_dev_token 309 342 310 343 311 344 async def require_auth( ··· 377 410 ) 378 411 379 412 return session 413 + 414 + 415 + @dataclass 416 + class DeveloperToken: 417 + """developer token metadata (without sensitive session data).""" 418 + 419 + session_id: str 420 + token_name: str | None 421 + created_at: datetime 422 + expires_at: datetime | None 423 + 424 + 425 + async def list_developer_tokens(did: str) -> list[DeveloperToken]: 426 + """list all developer tokens for a user.""" 427 + async with db_session() as db: 428 + result = await db.execute( 429 + select(UserSession).where( 430 + UserSession.did == did, 431 + UserSession.is_developer_token == True, # noqa: E712 432 + ) 433 + ) 434 + sessions = result.scalars().all() 435 + 436 + tokens = [] 437 + for session in sessions: 438 + # check if expired 439 + if session.expires_at and datetime.now(UTC) > session.expires_at: 440 + continue # skip expired tokens 441 + 442 + tokens.append( 443 + DeveloperToken( 444 + session_id=session.session_id, 445 + token_name=session.token_name, 446 + created_at=session.created_at, 447 + expires_at=session.expires_at, 448 + ) 449 + ) 450 + 451 + return tokens 452 + 453 + 454 + async def revoke_developer_token(did: str, session_id: str) -> bool: 455 + """revoke a developer token. returns True if successful, False if not found.""" 456 + async with db_session() as db: 457 + result = await db.execute( 458 + select(UserSession).where( 459 + UserSession.session_id == session_id, 460 + UserSession.did == did, # ensure user owns this token 461 + UserSession.is_developer_token == True, # noqa: E712 462 + ) 463 + ) 464 + session = result.scalar_one_or_none() 465 + 466 + if not session: 467 + return False 468 + 469 + await db.delete(session) 470 + await db.commit() 471 + return True 472 + 473 + 474 + @dataclass 475 + class PendingDevTokenData: 476 + """metadata for a pending developer token OAuth flow.""" 477 + 478 + state: str 479 + did: str 480 + token_name: str | None 481 + expires_in_days: int 482 + 483 + 484 + async def save_pending_dev_token( 485 + state: str, 486 + did: str, 487 + token_name: str | None, 488 + expires_in_days: int, 489 + ) -> None: 490 + """save pending dev token metadata keyed by OAuth state.""" 491 + async with db_session() as db: 492 + pending = PendingDevToken( 493 + state=state, 494 + did=did, 495 + token_name=token_name, 496 + expires_in_days=expires_in_days, 497 + ) 498 + db.add(pending) 499 + await db.commit() 500 + 501 + 502 + async def get_pending_dev_token(state: str) -> PendingDevTokenData | None: 503 + """get pending dev token metadata by OAuth state.""" 504 + async with db_session() as db: 505 + result = await db.execute( 506 + select(PendingDevToken).where(PendingDevToken.state == state) 507 + ) 508 + pending = result.scalar_one_or_none() 509 + 510 + if not pending: 511 + return None 512 + 513 + # check if expired 514 + if datetime.now(UTC) > pending.expires_at: 515 + await db.delete(pending) 516 + await db.commit() 517 + return None 518 + 519 + return PendingDevTokenData( 520 + state=pending.state, 521 + did=pending.did, 522 + token_name=pending.token_name, 523 + expires_in_days=pending.expires_in_days, 524 + ) 525 + 526 + 527 + async def delete_pending_dev_token(state: str) -> None: 528 + """delete pending dev token metadata after use.""" 529 + async with db_session() as db: 530 + result = await db.execute( 531 + select(PendingDevToken).where(PendingDevToken.state == state) 532 + ) 533 + if pending := result.scalar_one_or_none(): 534 + await db.delete(pending) 535 + await db.commit()
+170 -2
backend/src/backend/api/auth.py
··· 13 13 consume_exchange_token, 14 14 create_exchange_token, 15 15 create_session, 16 + delete_pending_dev_token, 16 17 delete_session, 18 + get_pending_dev_token, 17 19 handle_oauth_callback, 20 + list_developer_tokens, 18 21 require_auth, 22 + revoke_developer_token, 23 + save_pending_dev_token, 19 24 start_oauth_flow, 20 25 ) 21 26 from backend.config import settings ··· 49 54 50 55 returns exchange token in URL which frontend will exchange for session_id. 51 56 exchange token is short-lived (60s) and one-time use for security. 57 + 58 + if this is a developer token flow (state exists in pending_dev_tokens), 59 + creates a dev token session and redirects with dev_token=true flag. 52 60 """ 53 61 did, handle, oauth_session = await handle_oauth_callback(code, state, iss) 62 + 63 + # check if this is a developer token OAuth flow 64 + pending_dev_token = await get_pending_dev_token(state) 65 + 66 + if pending_dev_token: 67 + # verify the DID matches (user must be the one who started the flow) 68 + if pending_dev_token.did != did: 69 + raise HTTPException( 70 + status_code=403, 71 + detail="developer token flow was started by a different user", 72 + ) 73 + 74 + # create dev token session with its own OAuth credentials 75 + session_id = await create_session( 76 + did=did, 77 + handle=handle, 78 + oauth_session=oauth_session, 79 + expires_in_days=pending_dev_token.expires_in_days, 80 + is_developer_token=True, 81 + token_name=pending_dev_token.token_name, 82 + ) 83 + 84 + # clean up pending record 85 + await delete_pending_dev_token(state) 86 + 87 + # create exchange token (marked as dev token to prevent cookie override) 88 + exchange_token = await create_exchange_token(session_id, is_dev_token=True) 89 + 90 + return RedirectResponse( 91 + url=f"{settings.frontend.url}/portal?exchange_token={exchange_token}&dev_token=true", 92 + status_code=303, 93 + ) 94 + 95 + # regular login flow 54 96 session_id = await create_session(did, handle, oauth_session) 55 97 56 98 # create one-time exchange token (expires in 60 seconds) ··· 94 136 95 137 for browser requests: sets HttpOnly cookie and still returns session_id in response 96 138 for SDK/CLI clients: only returns session_id in response (no cookie) 139 + for dev token exchanges: returns session_id but does NOT set cookie 97 140 """ 98 - session_id = await consume_exchange_token(exchange_request.exchange_token) 141 + result = await consume_exchange_token(exchange_request.exchange_token) 99 142 100 - if not session_id: 143 + if not result: 101 144 raise HTTPException( 102 145 status_code=401, 103 146 detail="invalid, expired, or already used exchange token", 104 147 ) 148 + 149 + session_id, is_dev_token = result 150 + 151 + # don't set cookie for dev token exchanges - this prevents overwriting 152 + # the browser's session cookie when creating a dev token 153 + if is_dev_token: 154 + return ExchangeTokenResponse(session_id=session_id) 105 155 106 156 user_agent = request.headers.get("user-agent", "").lower() 107 157 is_browser = any( ··· 154 204 did=session.did, 155 205 handle=session.handle, 156 206 ) 207 + 208 + 209 + class DeveloperTokenInfo(BaseModel): 210 + """info about a developer token (without the actual token).""" 211 + 212 + session_id: str # first 8 chars only for identification 213 + name: str | None 214 + created_at: str # ISO format 215 + expires_at: str | None # ISO format or null for never 216 + 217 + 218 + class DeveloperTokenListResponse(BaseModel): 219 + """response model for listing developer tokens.""" 220 + 221 + tokens: list[DeveloperTokenInfo] 222 + 223 + 224 + @router.get("/developer-tokens") 225 + async def get_developer_tokens( 226 + session: Session = Depends(require_auth), 227 + ) -> DeveloperTokenListResponse: 228 + """list all developer tokens for the current user.""" 229 + tokens = await list_developer_tokens(session.did) 230 + 231 + return DeveloperTokenListResponse( 232 + tokens=[ 233 + DeveloperTokenInfo( 234 + session_id=t.session_id[:8], # only show prefix for identification 235 + name=t.token_name, 236 + created_at=t.created_at.isoformat(), 237 + expires_at=t.expires_at.isoformat() if t.expires_at else None, 238 + ) 239 + for t in tokens 240 + ] 241 + ) 242 + 243 + 244 + @router.delete("/developer-tokens/{token_prefix}") 245 + async def delete_developer_token( 246 + token_prefix: str, 247 + session: Session = Depends(require_auth), 248 + ) -> JSONResponse: 249 + """revoke a developer token by its prefix (first 8 chars of session_id).""" 250 + # find the full session_id from prefix 251 + tokens = await list_developer_tokens(session.did) 252 + matching = [t for t in tokens if t.session_id.startswith(token_prefix)] 253 + 254 + if not matching: 255 + raise HTTPException(status_code=404, detail="token not found") 256 + 257 + if len(matching) > 1: 258 + raise HTTPException( 259 + status_code=400, 260 + detail="ambiguous prefix - provide more characters", 261 + ) 262 + 263 + success = await revoke_developer_token(session.did, matching[0].session_id) 264 + if not success: 265 + raise HTTPException(status_code=404, detail="token not found") 266 + 267 + return JSONResponse(content={"message": "token revoked successfully"}) 268 + 269 + 270 + class DevTokenStartRequest(BaseModel): 271 + """request model for starting developer token OAuth flow.""" 272 + 273 + name: str | None = None 274 + expires_in_days: int | None = None 275 + 276 + 277 + class DevTokenStartResponse(BaseModel): 278 + """response model with OAuth authorization URL.""" 279 + 280 + auth_url: str 281 + 282 + 283 + @router.post("/developer-token/start") 284 + @limiter.limit(settings.rate_limit.auth_limit) 285 + async def start_developer_token_flow( 286 + request: Request, 287 + body: DevTokenStartRequest, 288 + session: Session = Depends(require_auth), 289 + ) -> DevTokenStartResponse: 290 + """start OAuth flow to create a developer token with its own credentials. 291 + 292 + this initiates a new OAuth authorization flow. the user will be redirected 293 + to authorize, and on callback a dev token with independent OAuth credentials 294 + will be created. this ensures dev tokens don't become stale when browser 295 + sessions refresh their tokens. 296 + 297 + returns the authorization URL that the frontend should redirect to. 298 + """ 299 + # validate expiration 300 + expires_in_days = ( 301 + body.expires_in_days 302 + if body.expires_in_days is not None 303 + else settings.auth.developer_token_default_days 304 + ) 305 + 306 + max_days = settings.auth.developer_token_max_days 307 + if expires_in_days > max_days: 308 + raise HTTPException( 309 + status_code=400, 310 + detail=f"expires_in_days cannot exceed {max_days} (use 0 for no expiration)", 311 + ) 312 + 313 + # start OAuth flow using the user's handle 314 + auth_url, state = await start_oauth_flow(session.handle) 315 + 316 + # save pending dev token metadata keyed by state 317 + await save_pending_dev_token( 318 + state=state, 319 + did=session.did, 320 + token_name=body.name, 321 + expires_in_days=expires_in_days, 322 + ) 323 + 324 + return DevTokenStartResponse(auth_url=auth_url)
+36 -12
backend/src/backend/config.py
··· 1 - """Relay configuration using nested Pydantic settings.""" 1 + """plyr.fm configuration using nested Pydantic settings.""" 2 2 3 3 from __future__ import annotations 4 4 ··· 11 11 BASE_DIR = Path(__file__).resolve().parents[2] 12 12 13 13 14 - class RelaySettingsSection(BaseSettings): 14 + class AppSettingsSection(BaseSettings): 15 15 """Base class for all settings sections with shared defaults.""" 16 16 17 17 model_config = SettingsConfigDict( ··· 22 22 ) 23 23 24 24 25 - class NotificationBotSettings(RelaySettingsSection): 25 + class NotificationBotSettings(AppSettingsSection): 26 26 """Settings for the notification bot.""" 27 27 28 28 model_config = SettingsConfigDict( ··· 36 36 password: str = Field(default="", description="App password for bot") 37 37 38 38 39 - class NotificationSettings(RelaySettingsSection): 39 + class NotificationSettings(AppSettingsSection): 40 40 """Settings for notifications.""" 41 41 42 42 model_config = SettingsConfigDict( ··· 56 56 ) 57 57 58 58 59 - class AppSettings(RelaySettingsSection): 59 + class AppSettings(AppSettingsSection): 60 60 """Core application configuration.""" 61 61 62 62 name: str = Field( ··· 86 86 ) 87 87 88 88 89 - class FrontendSettings(RelaySettingsSection): 89 + class FrontendSettings(AppSettingsSection): 90 90 """Frontend-specific configuration.""" 91 91 92 92 url: str = Field( ··· 128 128 return r"^(http://localhost:5173)$" 129 129 130 130 131 - class DatabaseSettings(RelaySettingsSection): 131 + class DatabaseSettings(AppSettingsSection): 132 132 """Database configuration.""" 133 133 134 134 url: str = Field( ··· 177 177 ) 178 178 179 179 180 - class StorageSettings(RelaySettingsSection): 180 + class StorageSettings(AppSettingsSection): 181 181 """Asset storage configuration (R2 only).""" 182 182 183 183 max_upload_size_mb: int = Field( ··· 257 257 return True 258 258 259 259 260 - class AtprotoSettings(RelaySettingsSection): 260 + class AtprotoSettings(AppSettingsSection): 261 261 """ATProto integration settings.""" 262 262 263 263 pds_url: str = Field( ··· 353 353 return f"atproto {' '.join(scopes)}" 354 354 355 355 356 - class ObservabilitySettings(RelaySettingsSection): 356 + class ObservabilitySettings(AppSettingsSection): 357 357 """Observability configuration.""" 358 358 359 359 enabled: bool = Field( ··· 373 373 ) 374 374 375 375 376 - class RateLimitSettings(RelaySettingsSection): 376 + class RateLimitSettings(AppSettingsSection): 377 377 """Rate limiting configuration.""" 378 378 379 379 model_config = SettingsConfigDict( ··· 401 401 ) 402 402 403 403 404 - class Settings(RelaySettingsSection): 404 + class AuthSettings(AppSettingsSection): 405 + """Authentication configuration.""" 406 + 407 + model_config = SettingsConfigDict( 408 + env_prefix="AUTH_", 409 + env_file=".env", 410 + case_sensitive=False, 411 + extra="ignore", 412 + ) 413 + 414 + developer_token_default_days: int = Field( 415 + default=90, 416 + description="Default expiration in days for developer tokens (0 = no expiration)", 417 + ) 418 + developer_token_max_days: int = Field( 419 + default=365, 420 + description="Maximum allowed expiration in days for developer tokens", 421 + ) 422 + 423 + 424 + class Settings(AppSettingsSection): 405 425 """Relay application settings.""" 406 426 407 427 model_config = SettingsConfigDict( ··· 421 441 rate_limit: RateLimitSettings = Field( 422 442 default_factory=RateLimitSettings, 423 443 description="Rate limiting settings", 444 + ) 445 + auth: AuthSettings = Field( 446 + default_factory=AuthSettings, 447 + description="Authentication settings", 424 448 ) 425 449 notify: NotificationSettings = Field( 426 450 default_factory=NotificationSettings,
+2
backend/src/backend/models/__init__.py
··· 7 7 from backend.models.exchange_token import ExchangeToken 8 8 from backend.models.job import Job 9 9 from backend.models.oauth_state import OAuthStateModel 10 + from backend.models.pending_dev_token import PendingDevToken 10 11 from backend.models.preferences import UserPreferences 11 12 from backend.models.queue import QueueState 12 13 from backend.models.session import UserSession ··· 23 24 "ExchangeToken", 24 25 "Job", 25 26 "OAuthStateModel", 27 + "PendingDevToken", 26 28 "QueueState", 27 29 "ScanResolution", 28 30 "Track",
+5 -1
backend/src/backend/models/exchange_token.py
··· 2 2 3 3 from datetime import UTC, datetime, timedelta 4 4 5 - from sqlalchemy import DateTime, String 5 + from sqlalchemy import Boolean, DateTime, String 6 6 from sqlalchemy.orm import Mapped, mapped_column 7 7 8 8 from backend.models.database import Base ··· 30 30 nullable=False, 31 31 ) 32 32 used: Mapped[bool] = mapped_column(default=False, nullable=False) 33 + # dev token exchanges should not set browser cookies 34 + is_dev_token: Mapped[bool] = mapped_column( 35 + Boolean, default=False, nullable=False, server_default="false" 36 + )
+37
backend/src/backend/models/pending_dev_token.py
··· 1 + """pending developer token model for OAuth flow metadata.""" 2 + 3 + from datetime import UTC, datetime, timedelta 4 + 5 + from sqlalchemy import DateTime, Integer, String 6 + from sqlalchemy.orm import Mapped, mapped_column 7 + 8 + from backend.models.database import Base 9 + 10 + 11 + class PendingDevToken(Base): 12 + """temporary record linking OAuth state to dev token creation metadata. 13 + 14 + when a user initiates the OAuth flow for creating a developer token, 15 + we store the token name and expiration here, keyed by the OAuth state. 16 + the callback checks this table to know if the flow is for a dev token. 17 + 18 + records expire after 10 minutes (matching OAuth state TTL). 19 + """ 20 + 21 + __tablename__ = "pending_dev_tokens" 22 + 23 + state: Mapped[str] = mapped_column(String(64), primary_key=True, index=True) 24 + did: Mapped[str] = mapped_column(String(256), nullable=False, index=True) 25 + token_name: Mapped[str | None] = mapped_column(String(100), nullable=True) 26 + expires_in_days: Mapped[int] = mapped_column(Integer, nullable=False, default=90) 27 + created_at: Mapped[datetime] = mapped_column( 28 + DateTime(timezone=True), 29 + default=lambda: datetime.now(UTC), 30 + nullable=False, 31 + index=True, # for cleanup queries 32 + ) 33 + expires_at: Mapped[datetime] = mapped_column( 34 + DateTime(timezone=True), 35 + default=lambda: datetime.now(UTC) + timedelta(minutes=10), 36 + nullable=False, 37 + )
+6 -1
backend/src/backend/models/session.py
··· 2 2 3 3 from datetime import UTC, datetime 4 4 5 - from sqlalchemy import DateTime, String, Text 5 + from sqlalchemy import Boolean, DateTime, String, Text 6 6 from sqlalchemy.orm import Mapped, mapped_column 7 7 8 8 from backend.models.database import Base ··· 27 27 expires_at: Mapped[datetime | None] = mapped_column( 28 28 DateTime(timezone=True), nullable=True 29 29 ) 30 + # developer token fields 31 + is_developer_token: Mapped[bool] = mapped_column( 32 + Boolean, default=False, nullable=False, server_default="false" 33 + ) 34 + token_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
+205
backend/tests/api/test_developer_tokens.py
··· 1 + """tests for developer token api endpoints.""" 2 + 3 + from collections.abc import Generator 4 + from unittest.mock import AsyncMock, patch 5 + 6 + import pytest 7 + from fastapi import FastAPI 8 + from httpx import ASGITransport, AsyncClient 9 + from sqlalchemy.ext.asyncio import AsyncSession 10 + 11 + from backend._internal import Session, create_session, require_auth 12 + from backend.main import app 13 + 14 + 15 + class MockSession(Session): 16 + """mock session for auth bypass in tests.""" 17 + 18 + def __init__(self, did: str = "did:test:user123"): 19 + self.did = did 20 + self.handle = "testuser.bsky.social" 21 + self.session_id = "test_session_id" 22 + self.access_token = "test_token" 23 + self.refresh_token = "test_refresh" 24 + self.oauth_session = { 25 + "did": did, 26 + "handle": "testuser.bsky.social", 27 + "pds_url": "https://test.pds", 28 + "authserver_iss": "https://auth.test", 29 + "scope": "atproto transition:generic", 30 + "access_token": "test_token", 31 + "refresh_token": "test_refresh", 32 + "dpop_private_key_pem": "fake_key", 33 + "dpop_authserver_nonce": "", 34 + "dpop_pds_nonce": "", 35 + } 36 + 37 + 38 + @pytest.fixture 39 + def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 40 + """create test app with mocked auth.""" 41 + 42 + async def mock_require_auth() -> Session: 43 + return MockSession() 44 + 45 + app.dependency_overrides[require_auth] = mock_require_auth 46 + 47 + yield app 48 + 49 + app.dependency_overrides.clear() 50 + 51 + 52 + async def test_start_developer_token_flow(test_app: FastAPI, db_session: AsyncSession): 53 + """test starting the developer token OAuth flow.""" 54 + with patch( 55 + "backend.api.auth.start_oauth_flow", new_callable=AsyncMock 56 + ) as mock_oauth: 57 + mock_oauth.return_value = ( 58 + "https://auth.example.com/authorize?...", 59 + "test_state", 60 + ) 61 + 62 + async with AsyncClient( 63 + transport=ASGITransport(app=test_app), base_url="http://test" 64 + ) as client: 65 + response = await client.post( 66 + "/auth/developer-token/start", 67 + json={"name": "my-token", "expires_in_days": 30}, 68 + ) 69 + 70 + assert response.status_code == 200 71 + data = response.json() 72 + assert "auth_url" in data 73 + assert data["auth_url"].startswith("https://auth.example.com") 74 + mock_oauth.assert_called_once_with("testuser.bsky.social") 75 + 76 + 77 + async def test_start_developer_token_default_expiration( 78 + test_app: FastAPI, db_session: AsyncSession 79 + ): 80 + """test starting dev token flow with default expiration.""" 81 + with patch( 82 + "backend.api.auth.start_oauth_flow", new_callable=AsyncMock 83 + ) as mock_oauth: 84 + mock_oauth.return_value = ("https://auth.example.com/authorize", "test_state") 85 + 86 + async with AsyncClient( 87 + transport=ASGITransport(app=test_app), base_url="http://test" 88 + ) as client: 89 + response = await client.post( 90 + "/auth/developer-token/start", 91 + json={}, 92 + ) 93 + 94 + assert response.status_code == 200 95 + # verify pending dev token was saved (would fail if expiration wasn't set) 96 + 97 + 98 + async def test_start_developer_token_exceeds_max( 99 + test_app: FastAPI, db_session: AsyncSession 100 + ): 101 + """test that expiration cannot exceed max allowed.""" 102 + async with AsyncClient( 103 + transport=ASGITransport(app=test_app), base_url="http://test" 104 + ) as client: 105 + response = await client.post( 106 + "/auth/developer-token/start", 107 + json={"expires_in_days": 999}, # exceeds default max of 365 108 + ) 109 + 110 + assert response.status_code == 400 111 + assert "cannot exceed" in response.json()["detail"] 112 + 113 + 114 + async def test_start_developer_token_requires_auth(db_session: AsyncSession): 115 + """test that developer token start requires authentication.""" 116 + async with AsyncClient( 117 + transport=ASGITransport(app=app), base_url="http://test" 118 + ) as client: 119 + response = await client.post( 120 + "/auth/developer-token/start", 121 + json={}, 122 + ) 123 + 124 + assert response.status_code == 401 125 + 126 + 127 + async def test_list_developer_tokens(test_app: FastAPI, db_session: AsyncSession): 128 + """test listing developer tokens.""" 129 + mock_session = MockSession() 130 + 131 + # create a dev token directly in the database 132 + await create_session( 133 + did=mock_session.did, 134 + handle=mock_session.handle, 135 + oauth_session=mock_session.oauth_session, 136 + expires_in_days=30, 137 + is_developer_token=True, 138 + token_name="list-test-token", 139 + ) 140 + 141 + async with AsyncClient( 142 + transport=ASGITransport(app=test_app), base_url="http://test" 143 + ) as client: 144 + response = await client.get("/auth/developer-tokens") 145 + 146 + assert response.status_code == 200 147 + data = response.json() 148 + assert "tokens" in data 149 + assert len(data["tokens"]) >= 1 150 + 151 + # find our token 152 + token = next((t for t in data["tokens"] if t["name"] == "list-test-token"), None) 153 + assert token is not None 154 + assert "session_id" in token 155 + assert "created_at" in token 156 + 157 + 158 + async def test_revoke_developer_token(test_app: FastAPI, db_session: AsyncSession): 159 + """test revoking a developer token.""" 160 + mock_session = MockSession() 161 + 162 + # create a dev token directly 163 + await create_session( 164 + did=mock_session.did, 165 + handle=mock_session.handle, 166 + oauth_session=mock_session.oauth_session, 167 + expires_in_days=30, 168 + is_developer_token=True, 169 + token_name="revoke-test-token", 170 + ) 171 + 172 + async with AsyncClient( 173 + transport=ASGITransport(app=test_app), base_url="http://test" 174 + ) as client: 175 + # list tokens to get session_id prefix 176 + list_response = await client.get("/auth/developer-tokens") 177 + assert list_response.status_code == 200 178 + tokens = list_response.json()["tokens"] 179 + token = next((t for t in tokens if t["name"] == "revoke-test-token"), None) 180 + assert token is not None 181 + 182 + # revoke the token 183 + revoke_response = await client.delete( 184 + f"/auth/developer-tokens/{token['session_id']}" 185 + ) 186 + assert revoke_response.status_code == 200 187 + assert revoke_response.json()["message"] == "token revoked successfully" 188 + 189 + # verify it's gone 190 + final_list = await client.get("/auth/developer-tokens") 191 + remaining = [ 192 + t for t in final_list.json()["tokens"] if t["name"] == "revoke-test-token" 193 + ] 194 + assert len(remaining) == 0 195 + 196 + 197 + async def test_revoke_nonexistent_token(test_app: FastAPI, db_session: AsyncSession): 198 + """test revoking a token that doesn't exist.""" 199 + async with AsyncClient( 200 + transport=ASGITransport(app=test_app), base_url="http://test" 201 + ) as client: 202 + response = await client.delete("/auth/developer-tokens/nonexist") 203 + 204 + assert response.status_code == 404 205 + assert response.json()["detail"] == "token not found"
+10 -2
backend/tests/conftest.py
··· 171 171 172 172 173 173 @pytest.fixture() 174 - async def _engine(test_database_url: str, _database_setup: None) -> AsyncEngine: 174 + async def _engine( 175 + test_database_url: str, _database_setup: None 176 + ) -> AsyncGenerator[AsyncEngine, None]: 175 177 """create a database engine for each test (to avoid event loop issues).""" 176 - return create_async_engine( 178 + engine = create_async_engine( 177 179 test_database_url, 178 180 echo=False, 181 + pool_size=2, 182 + max_overflow=0, 179 183 ) 184 + try: 185 + yield engine 186 + finally: 187 + await engine.dispose() 180 188 181 189 182 190 @pytest.fixture()
+87 -4
backend/tests/test_auth.py
··· 154 154 token = await create_exchange_token(session_id) 155 155 156 156 # verify token can be consumed (proves it was created correctly) 157 - returned_session_id = await consume_exchange_token(token) 157 + result = await consume_exchange_token(token) 158 + assert result is not None 159 + returned_session_id, is_dev_token = result 158 160 assert returned_session_id == session_id 161 + assert is_dev_token is False 159 162 160 163 # verify token can't be reused 161 164 second_attempt = await consume_exchange_token(token) ··· 172 175 token = await create_exchange_token(session_id) 173 176 174 177 # consume token 175 - returned_session_id = await consume_exchange_token(token) 178 + result = await consume_exchange_token(token) 179 + assert result is not None 180 + returned_session_id, is_dev_token = result 176 181 assert returned_session_id == session_id 182 + assert is_dev_token is False 177 183 178 184 # verify token can't be consumed again (proves it was marked as used) 179 185 second_attempt = await consume_exchange_token(token) ··· 190 196 token = await create_exchange_token(session_id) 191 197 192 198 # consume token first time 193 - first_result = await consume_exchange_token(token) 194 - assert first_result == session_id 199 + result = await consume_exchange_token(token) 200 + assert result is not None 201 + returned_session_id, is_dev_token = result 202 + assert returned_session_id == session_id 195 203 196 204 # try to consume again - should return None 197 205 second_result = await consume_exchange_token(token) ··· 243 251 result = await db_session.execute(select(ExchangeToken)) 244 252 tokens = result.scalars().all() 245 253 assert len(tokens) == 0 254 + 255 + 256 + async def test_create_session_with_custom_expiration(db_session: AsyncSession): 257 + """verify session creation with custom expiration works.""" 258 + did = "did:plc:customexp123" 259 + handle = "customexp.bsky.social" 260 + oauth_data = {"access_token": "token", "refresh_token": "refresh"} 261 + 262 + # create session with 30-day expiration 263 + session_id = await create_session(did, handle, oauth_data, expires_in_days=30) 264 + 265 + # verify session exists and works 266 + session = await get_session(session_id) 267 + assert session is not None 268 + assert session.did == did 269 + 270 + # verify expiration is set (within reasonable range) 271 + result = await db_session.execute( 272 + select(UserSession).where(UserSession.session_id == session_id) 273 + ) 274 + db_session_record = result.scalar_one_or_none() 275 + assert db_session_record is not None 276 + assert db_session_record.expires_at is not None 277 + 278 + # should expire roughly 30 days from now 279 + expected_expiry = datetime.now(UTC) + timedelta(days=30) 280 + actual_expiry = db_session_record.expires_at.replace(tzinfo=UTC) 281 + diff = abs((expected_expiry - actual_expiry).total_seconds()) 282 + assert diff < 60 # within 1 minute 283 + 284 + 285 + async def test_create_session_with_no_expiration(db_session: AsyncSession): 286 + """verify session creation with expires_in_days=0 creates non-expiring session.""" 287 + did = "did:plc:noexp123" 288 + handle = "noexp.bsky.social" 289 + oauth_data = {"access_token": "token", "refresh_token": "refresh"} 290 + 291 + # create session with no expiration 292 + session_id = await create_session(did, handle, oauth_data, expires_in_days=0) 293 + 294 + # verify session exists 295 + session = await get_session(session_id) 296 + assert session is not None 297 + assert session.did == did 298 + 299 + # verify expires_at is None 300 + result = await db_session.execute( 301 + select(UserSession).where(UserSession.session_id == session_id) 302 + ) 303 + db_session_record = result.scalar_one_or_none() 304 + assert db_session_record is not None 305 + assert db_session_record.expires_at is None 306 + 307 + 308 + async def test_create_session_default_expiration(db_session: AsyncSession): 309 + """verify session creation uses default 14-day expiration.""" 310 + did = "did:plc:defaultexp123" 311 + handle = "defaultexp.bsky.social" 312 + oauth_data = {"access_token": "token", "refresh_token": "refresh"} 313 + 314 + # create session with default expiration 315 + session_id = await create_session(did, handle, oauth_data) 316 + 317 + # verify expiration is set to default (14 days) 318 + result = await db_session.execute( 319 + select(UserSession).where(UserSession.session_id == session_id) 320 + ) 321 + db_session_record = result.scalar_one_or_none() 322 + assert db_session_record is not None 323 + assert db_session_record.expires_at is not None 324 + 325 + expected_expiry = datetime.now(UTC) + timedelta(days=14) 326 + actual_expiry = db_session_record.expires_at.replace(tzinfo=UTC) 327 + diff = abs((expected_expiry - actual_expiry).total_seconds()) 328 + assert diff < 60 # within 1 minute
+160
backend/tests/test_integration_upload.py
··· 1 + """integration tests for track upload/delete using real API token. 2 + 3 + these tests require: 4 + - PLYRFM_API_TOKEN or PLYR_TOKEN env var 5 + - running backend (local or remote) 6 + - set PLYR_API_URL for non-local testing (default: http://localhost:8001) 7 + 8 + run with: uv run pytest tests/test_integration_upload.py -m integration -v 9 + """ 10 + 11 + import json 12 + import os 13 + import struct 14 + import tempfile 15 + from collections.abc import Generator 16 + from pathlib import Path 17 + 18 + import httpx 19 + import pytest 20 + 21 + API_URL = os.getenv("PLYR_API_URL", "http://localhost:8001") 22 + TOKEN = os.getenv("PLYR_TOKEN") or os.getenv("PLYRFM_API_TOKEN") 23 + 24 + 25 + def generate_wav_file(duration_seconds: float = 1.0, sample_rate: int = 44100) -> bytes: 26 + """generate a minimal valid WAV file with silence.""" 27 + num_channels = 1 28 + bits_per_sample = 16 29 + num_samples = int(sample_rate * duration_seconds) 30 + data_size = num_samples * num_channels * (bits_per_sample // 8) 31 + 32 + # WAV header 33 + header = struct.pack( 34 + "<4sI4s4sIHHIIHH4sI", 35 + b"RIFF", 36 + 36 + data_size, # file size - 8 37 + b"WAVE", 38 + b"fmt ", 39 + 16, # fmt chunk size 40 + 1, # audio format (PCM) 41 + num_channels, 42 + sample_rate, 43 + sample_rate * num_channels * (bits_per_sample // 8), # byte rate 44 + num_channels * (bits_per_sample // 8), # block align 45 + bits_per_sample, 46 + b"data", 47 + data_size, 48 + ) 49 + 50 + # silence (zeros) 51 + audio_data = b"\x00" * data_size 52 + 53 + return header + audio_data 54 + 55 + 56 + @pytest.fixture 57 + def test_audio_file() -> Generator[Path, None, None]: 58 + """create a temporary test audio file.""" 59 + wav_data = generate_wav_file(duration_seconds=1.0) 60 + 61 + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: 62 + f.write(wav_data) 63 + path = Path(f.name) 64 + 65 + yield path 66 + 67 + # cleanup 68 + path.unlink(missing_ok=True) 69 + 70 + 71 + @pytest.mark.integration 72 + async def test_upload_and_delete_track(test_audio_file: Path): 73 + """integration test: upload a track, wait for processing, then delete it.""" 74 + if not TOKEN: 75 + pytest.skip("PLYR_TOKEN or PLYRFM_API_TOKEN not set") 76 + 77 + async with httpx.AsyncClient(timeout=120.0) as client: 78 + # 1. verify auth works 79 + auth_response = await client.get( 80 + f"{API_URL}/auth/me", 81 + headers={"Authorization": f"Bearer {TOKEN}"}, 82 + ) 83 + if auth_response.status_code == 401: 84 + pytest.skip("token is invalid or expired") 85 + assert auth_response.status_code == 200, f"auth failed: {auth_response.text}" 86 + user = auth_response.json() 87 + print(f"authenticated as: {user['handle']}") 88 + 89 + # 2. upload track 90 + with open(test_audio_file, "rb") as f: 91 + files = {"file": ("test_integration.wav", f, "audio/wav")} 92 + data = {"title": "Integration Test Track (DELETE ME)"} 93 + 94 + upload_response = await client.post( 95 + f"{API_URL}/tracks/", 96 + headers={"Authorization": f"Bearer {TOKEN}"}, 97 + files=files, 98 + data=data, 99 + ) 100 + 101 + if upload_response.status_code == 403: 102 + detail = upload_response.json().get("detail", "") 103 + if "artist_profile_required" in detail: 104 + pytest.skip("user needs artist profile setup") 105 + if "scope_upgrade_required" in detail: 106 + pytest.skip("token needs re-authorization with new scopes") 107 + 108 + assert upload_response.status_code == 200, ( 109 + f"upload failed: {upload_response.text}" 110 + ) 111 + 112 + upload_data = upload_response.json() 113 + upload_id = upload_data["upload_id"] 114 + print(f"upload started: {upload_id}") 115 + 116 + # 3. poll for completion via SSE 117 + track_id = None 118 + async with client.stream( 119 + "GET", 120 + f"{API_URL}/tracks/uploads/{upload_id}/progress", 121 + headers={"Authorization": f"Bearer {TOKEN}"}, 122 + ) as response: 123 + async for line in response.aiter_lines(): 124 + if line.startswith("data: "): 125 + data = json.loads(line[6:]) 126 + status = data.get("status") 127 + print(f" status: {status} - {data.get('message', '')}") 128 + 129 + if status == "completed": 130 + track_id = data.get("track_id") 131 + print(f"upload complete! track_id: {track_id}") 132 + break 133 + elif status == "failed": 134 + error = data.get("error", "unknown error") 135 + pytest.fail(f"upload failed: {error}") 136 + 137 + assert track_id is not None, "upload completed but no track_id returned" 138 + 139 + # 4. verify track exists 140 + track_response = await client.get(f"{API_URL}/tracks/{track_id}") 141 + assert track_response.status_code == 200, ( 142 + f"track not found: {track_response.text}" 143 + ) 144 + track = track_response.json() 145 + print(f"track created: {track['title']} by {track['artist']['handle']}") 146 + 147 + # 5. delete track 148 + delete_response = await client.delete( 149 + f"{API_URL}/tracks/{track_id}", 150 + headers={"Authorization": f"Bearer {TOKEN}"}, 151 + ) 152 + assert delete_response.status_code == 200, ( 153 + f"delete failed: {delete_response.text}" 154 + ) 155 + print(f"track {track_id} deleted successfully") 156 + 157 + # 6. verify track is gone 158 + verify_response = await client.get(f"{API_URL}/tracks/{track_id}") 159 + assert verify_response.status_code == 404, "track should be deleted" 160 + print("verified track no longer exists")
+106
docs/authentication.md
··· 356 356 2. frontend and backend both on `localhost` (not `127.0.0.1`)? 357 357 3. backend using `secure=False` for localhost? 358 358 359 + ## developer tokens (programmatic access) 360 + 361 + for scripts, CLIs, and automated workflows, create a long-lived developer token: 362 + 363 + ### creating a token 364 + 365 + **via UI (recommended)**: 366 + 1. go to portal → "your data" → "developer tokens" section 367 + 2. optionally enter a name (e.g., "upload-script", "ci-pipeline") 368 + 3. select expiration (30/90/180/365 days or never) 369 + 4. click "create token" 370 + 5. **authorize at your PDS** (you'll be redirected to approve the OAuth grant) 371 + 6. copy the token immediately after redirect (shown only once) 372 + 373 + **via API**: 374 + ```javascript 375 + // step 1: start OAuth flow (returns auth_url to redirect to) 376 + const response = await fetch('/auth/developer-token/start', { 377 + method: 'POST', 378 + headers: {'Content-Type': 'application/json'}, 379 + credentials: 'include', 380 + body: JSON.stringify({ name: 'my-script', expires_in_days: 90 }) 381 + }); 382 + const { auth_url } = await response.json(); 383 + // step 2: redirect user to auth_url to authorize at their PDS 384 + // step 3: on callback, token is returned via exchange flow 385 + ``` 386 + 387 + ### managing tokens 388 + 389 + **list active tokens**: 390 + the portal shows all your active developer tokens with: 391 + - token name (or auto-generated identifier) 392 + - creation date 393 + - expiration date 394 + 395 + **revoke a token**: 396 + 1. go to portal → "your data" → "developer tokens" 397 + 2. find the token in the list 398 + 3. click "revoke" to immediately invalidate it 399 + 400 + **via API**: 401 + ```bash 402 + # list tokens 403 + curl -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/developer-tokens 404 + 405 + # revoke by prefix (first 8 chars shown in list) 406 + curl -X DELETE -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/developer-tokens/abc12345 407 + ``` 408 + 409 + ### using tokens 410 + 411 + set the token in your environment: 412 + ```bash 413 + export PLYR_TOKEN="your_token_here" 414 + ``` 415 + 416 + use with any authenticated endpoint: 417 + ```bash 418 + # check auth 419 + curl -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/me 420 + ``` 421 + 422 + **CLI usage** (`scripts/plyr.py`): 423 + ```bash 424 + # list your tracks 425 + PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py list 426 + 427 + # upload a track 428 + PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py upload track.mp3 "My Track" 429 + 430 + # download a track 431 + PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py download 42 -o my-track.mp3 432 + 433 + # delete a track 434 + PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py delete 42 -y 435 + ``` 436 + 437 + ### configuration 438 + 439 + backend settings in `AuthSettings`: 440 + - `developer_token_default_days`: default expiration (90 days) 441 + - `developer_token_max_days`: max allowed expiration (365 days) 442 + - use `expires_in_days: 0` for tokens that never expire 443 + 444 + ### how it works 445 + 446 + developer tokens are sessions with their own independent OAuth grant. when you create a dev token, you go through a full OAuth authorization flow at your PDS, which gives the token its own access/refresh credentials. this means: 447 + - dev tokens can refresh independently (no staleness when browser session refreshes) 448 + - each token has its own DPoP keypair for request signing 449 + - logging out of browser doesn't affect dev tokens (cookie isolation) 450 + - revoking browser session doesn't affect dev tokens 451 + 452 + dev tokens can: 453 + - read your data (tracks, likes, profile) 454 + - upload tracks (creates ATProto records on your PDS) 455 + - perform any authenticated action 456 + 457 + **security notes**: 458 + - tokens have full account access - treat like passwords 459 + - revoke individual tokens via the portal or API 460 + - each token is independent - revoking one doesn't affect others 461 + - token names help identify which token is used where 462 + - tokens require explicit OAuth consent at your PDS 463 + 359 464 ## references 360 465 361 466 - [MDN: HttpOnly cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#security) ··· 366 471 - PR #239: frontend localStorage removal 367 472 - PR #243: backend cookie implementation 368 473 - PR #244: merged cookie-based auth 474 + - PR #367: developer tokens with independent OAuth grants
+1 -1
docs/backend/transcoder.md
··· 224 224 if needed in the future, add to `src/backend/config.py`: 225 225 226 226 ```python 227 - class TranscoderSettings(RelaySettingsSection): 227 + class TranscoderSettings(AppSettingsSection): 228 228 url: str = Field( 229 229 default="https://plyr-transcoder.fly.dev", 230 230 validation_alias="TRANSCODER_URL"
+437 -2
frontend/src/routes/portal/+page.svelte
··· 71 71 let deleteAtprotoRecords = $state(false); 72 72 let deleting = $state(false); 73 73 74 + // developer token state 75 + let creatingToken = $state(false); 76 + let developerToken = $state<string | null>(null); 77 + let tokenExpiresDays = $state(90); 78 + let tokenName = $state(''); 79 + let tokenCopied = $state(false); 80 + 81 + // existing tokens list 82 + interface TokenInfo { 83 + session_id: string; 84 + name: string | null; 85 + created_at: string; 86 + expires_at: string | null; 87 + } 88 + let existingTokens = $state<TokenInfo[]>([]); 89 + let loadingTokens = $state(false); 90 + let revokingToken = $state<string | null>(null); 91 + 74 92 onMount(async () => { 75 93 // check if exchange_token is in URL (from OAuth callback) 76 94 const params = new URLSearchParams(window.location.search); 77 95 const exchangeToken = params.get('exchange_token'); 96 + const isDevToken = params.get('dev_token') === 'true'; 78 97 79 98 if (exchangeToken) { 80 - // exchange token for session_id (cookie is set automatically by backend) 99 + // exchange token for session_id 81 100 try { 82 101 const exchangeResponse = await fetch(`${API_URL}/auth/exchange`, { 83 102 method: 'POST', ··· 87 106 }); 88 107 89 108 if (exchangeResponse.ok) { 90 - await auth.initialize(); 109 + const data = await exchangeResponse.json(); 110 + 111 + if (isDevToken) { 112 + // this is a developer token - display it to the user 113 + developerToken = data.session_id; 114 + toast.success('developer token created - save it now!'); 115 + } else { 116 + // regular login - initialize auth 117 + await auth.initialize(); 118 + } 91 119 } 92 120 } catch (_e) { 93 121 console.error('failed to exchange token:', _e); ··· 112 140 await loadArtistProfile(); 113 141 await loadMyAlbums(); 114 142 await loadPreferences(); 143 + await loadDeveloperTokens(); 115 144 } catch (_e) { 116 145 console.error('error loading portal data:', _e); 117 146 error = 'failed to load portal data'; ··· 119 148 loading = false; 120 149 } 121 150 }); 151 + 152 + async function loadDeveloperTokens() { 153 + loadingTokens = true; 154 + try { 155 + const response = await fetch(`${API_URL}/auth/developer-tokens`, { 156 + credentials: 'include' 157 + }); 158 + if (response.ok) { 159 + const data = await response.json(); 160 + existingTokens = data.tokens; 161 + } 162 + } catch (_e) { 163 + console.error('failed to load developer tokens:', _e); 164 + } finally { 165 + loadingTokens = false; 166 + } 167 + } 122 168 123 169 async function loadMyTracks() { 124 170 loadingTracks = true; ··· 554 600 } 555 601 } 556 602 603 + async function createDeveloperToken() { 604 + creatingToken = true; 605 + developerToken = null; 606 + tokenCopied = false; 607 + 608 + try { 609 + // start OAuth flow for dev token - this returns an auth URL 610 + const response = await fetch(`${API_URL}/auth/developer-token/start`, { 611 + method: 'POST', 612 + headers: { 'Content-Type': 'application/json' }, 613 + credentials: 'include', 614 + body: JSON.stringify({ 615 + name: tokenName || null, 616 + expires_in_days: tokenExpiresDays 617 + }) 618 + }); 619 + 620 + if (!response.ok) { 621 + const error = await response.json(); 622 + toast.error(error.detail || 'failed to start token creation'); 623 + creatingToken = false; 624 + return; 625 + } 626 + 627 + const result = await response.json(); 628 + tokenName = ''; // clear the name field 629 + 630 + // redirect to PDS for authorization 631 + // on callback, user will return with dev_token=true and the token will be displayed 632 + window.location.href = result.auth_url; 633 + } catch (e) { 634 + console.error('failed to create token:', e); 635 + toast.error('failed to create token'); 636 + creatingToken = false; 637 + } 638 + // note: we don't set creatingToken = false here because we're redirecting 639 + } 640 + 641 + async function revokeToken(tokenId: string, name: string | null) { 642 + if (!confirm(`revoke token "${name || tokenId}"?`)) return; 643 + 644 + revokingToken = tokenId; 645 + try { 646 + const response = await fetch(`${API_URL}/auth/developer-tokens/${tokenId}`, { 647 + method: 'DELETE', 648 + credentials: 'include' 649 + }); 650 + 651 + if (!response.ok) { 652 + const error = await response.json(); 653 + toast.error(error.detail || 'failed to revoke token'); 654 + return; 655 + } 656 + 657 + toast.success('token revoked'); 658 + await loadDeveloperTokens(); 659 + } catch (e) { 660 + console.error('failed to revoke token:', e); 661 + toast.error('failed to revoke token'); 662 + } finally { 663 + revokingToken = null; 664 + } 665 + } 666 + 667 + async function copyToken() { 668 + if (!developerToken) return; 669 + try { 670 + await navigator.clipboard.writeText(developerToken); 671 + tokenCopied = true; 672 + toast.success('token copied to clipboard'); 673 + setTimeout(() => { tokenCopied = false; }, 2000); 674 + } catch (e) { 675 + console.error('failed to copy:', e); 676 + toast.error('failed to copy token'); 677 + } 678 + } 679 + 557 680 async function deleteAccount() { 558 681 if (!auth.user || deleteConfirmText !== auth.user.handle) return; 559 682 ··· 1051 1174 </button> 1052 1175 </div> 1053 1176 {/if} 1177 + 1178 + <div class="data-control developer-section"> 1179 + <div class="control-info"> 1180 + <h3>developer tokens</h3> 1181 + <p class="control-description"> 1182 + create tokens for programmatic API access (uploads, track management) 1183 + </p> 1184 + </div> 1185 + 1186 + {#if loadingTokens} 1187 + <p class="loading-tokens">loading tokens...</p> 1188 + {:else if existingTokens.length > 0} 1189 + <div class="existing-tokens"> 1190 + <h4 class="tokens-header">active tokens</h4> 1191 + <div class="tokens-list"> 1192 + {#each existingTokens as token} 1193 + <div class="token-item"> 1194 + <div class="token-info"> 1195 + <span class="token-name">{token.name || `token_${token.session_id}`}</span> 1196 + <span class="token-meta"> 1197 + created {new Date(token.created_at).toLocaleDateString()} 1198 + {#if token.expires_at} 1199 + · expires {new Date(token.expires_at).toLocaleDateString()} 1200 + {:else} 1201 + · never expires 1202 + {/if} 1203 + </span> 1204 + </div> 1205 + <button 1206 + class="revoke-btn" 1207 + onclick={() => revokeToken(token.session_id, token.name)} 1208 + disabled={revokingToken === token.session_id} 1209 + title="revoke token" 1210 + > 1211 + {revokingToken === token.session_id ? '...' : 'revoke'} 1212 + </button> 1213 + </div> 1214 + {/each} 1215 + </div> 1216 + </div> 1217 + {/if} 1218 + 1219 + {#if developerToken} 1220 + <div class="token-display"> 1221 + <code class="token-value">{developerToken}</code> 1222 + <button 1223 + class="copy-btn" 1224 + onclick={copyToken} 1225 + title="copy token" 1226 + > 1227 + {tokenCopied ? '✓' : 'copy'} 1228 + </button> 1229 + <button 1230 + class="dismiss-btn" 1231 + onclick={() => developerToken = null} 1232 + title="dismiss" 1233 + > 1234 + 1235 + </button> 1236 + </div> 1237 + <p class="token-warning"> 1238 + save this token now - you won't be able to see it again 1239 + </p> 1240 + {:else} 1241 + <div class="token-form"> 1242 + <input 1243 + type="text" 1244 + class="token-name-input" 1245 + bind:value={tokenName} 1246 + placeholder="token name (optional)" 1247 + disabled={creatingToken} 1248 + /> 1249 + <label class="expires-label"> 1250 + <span>expires in</span> 1251 + <select bind:value={tokenExpiresDays} class="expires-select"> 1252 + <option value={30}>30 days</option> 1253 + <option value={90}>90 days</option> 1254 + <option value={180}>180 days</option> 1255 + <option value={365}>1 year</option> 1256 + <option value={0}>never</option> 1257 + </select> 1258 + </label> 1259 + <button 1260 + class="create-token-btn" 1261 + onclick={createDeveloperToken} 1262 + disabled={creatingToken} 1263 + > 1264 + {creatingToken ? 'creating...' : 'create token'} 1265 + </button> 1266 + </div> 1267 + {/if} 1268 + </div> 1054 1269 1055 1270 <div class="data-control danger-zone"> 1056 1271 <div class="control-info"> ··· 2108 2323 font-size: 0.85rem; 2109 2324 color: #888; 2110 2325 min-width: 60px; 2326 + } 2327 + 2328 + /* developer token section */ 2329 + .developer-section { 2330 + flex-direction: column; 2331 + align-items: stretch; 2332 + gap: 1rem; 2333 + } 2334 + 2335 + .developer-section .control-info h3 { 2336 + color: var(--accent); 2337 + } 2338 + 2339 + .token-form { 2340 + display: flex; 2341 + align-items: center; 2342 + gap: 1rem; 2343 + flex-wrap: wrap; 2344 + } 2345 + 2346 + .expires-label { 2347 + display: flex; 2348 + align-items: center; 2349 + gap: 0.5rem; 2350 + font-size: 0.9rem; 2351 + color: #888; 2352 + } 2353 + 2354 + .expires-select { 2355 + padding: 0.5rem 0.75rem; 2356 + background: #0a0a0a; 2357 + border: 1px solid #333; 2358 + border-radius: 4px; 2359 + color: white; 2360 + font-size: 0.9rem; 2361 + font-family: inherit; 2362 + cursor: pointer; 2363 + } 2364 + 2365 + .expires-select:focus { 2366 + outline: none; 2367 + border-color: var(--accent); 2368 + } 2369 + 2370 + .create-token-btn { 2371 + padding: 0.6rem 1.25rem; 2372 + background: var(--accent); 2373 + color: white; 2374 + border: none; 2375 + border-radius: 6px; 2376 + font-size: 0.9rem; 2377 + font-weight: 600; 2378 + cursor: pointer; 2379 + transition: all 0.2s; 2380 + white-space: nowrap; 2381 + width: auto; 2382 + } 2383 + 2384 + .create-token-btn:hover:not(:disabled) { 2385 + filter: brightness(1.1); 2386 + transform: translateY(-1px); 2387 + } 2388 + 2389 + .create-token-btn:disabled { 2390 + opacity: 0.5; 2391 + cursor: not-allowed; 2392 + transform: none; 2393 + } 2394 + 2395 + .token-display { 2396 + display: flex; 2397 + align-items: center; 2398 + gap: 0.5rem; 2399 + background: #0a0a0a; 2400 + border: 1px solid #333; 2401 + border-radius: 6px; 2402 + padding: 0.75rem; 2403 + overflow: hidden; 2404 + } 2405 + 2406 + .token-value { 2407 + flex: 1; 2408 + font-family: monospace; 2409 + font-size: 0.85rem; 2410 + color: #5ce87b; 2411 + word-break: break-all; 2412 + user-select: all; 2413 + } 2414 + 2415 + .copy-btn, 2416 + .dismiss-btn { 2417 + padding: 0.4rem 0.75rem; 2418 + background: #2a2a2a; 2419 + border: 1px solid #3a3a3a; 2420 + border-radius: 4px; 2421 + color: #888; 2422 + font-size: 0.85rem; 2423 + cursor: pointer; 2424 + transition: all 0.2s; 2425 + width: auto; 2426 + } 2427 + 2428 + .copy-btn:hover { 2429 + background: #3a3a3a; 2430 + border-color: var(--accent); 2431 + color: var(--accent); 2432 + } 2433 + 2434 + .dismiss-btn:hover { 2435 + background: #3a3a3a; 2436 + border-color: #666; 2437 + color: #aaa; 2438 + } 2439 + 2440 + .token-warning { 2441 + font-size: 0.85rem; 2442 + color: #e9a545; 2443 + margin: 0; 2444 + } 2445 + 2446 + /* existing tokens list */ 2447 + .existing-tokens { 2448 + width: 100%; 2449 + margin-bottom: 1rem; 2450 + } 2451 + 2452 + .tokens-header { 2453 + font-size: 0.9rem; 2454 + font-weight: 600; 2455 + color: #888; 2456 + margin: 0 0 0.75rem 0; 2457 + } 2458 + 2459 + .tokens-list { 2460 + display: flex; 2461 + flex-direction: column; 2462 + gap: 0.5rem; 2463 + } 2464 + 2465 + .token-item { 2466 + display: flex; 2467 + justify-content: space-between; 2468 + align-items: center; 2469 + gap: 1rem; 2470 + padding: 0.75rem; 2471 + background: #0a0a0a; 2472 + border: 1px solid #2a2a2a; 2473 + border-radius: 6px; 2474 + } 2475 + 2476 + .token-info { 2477 + display: flex; 2478 + flex-direction: column; 2479 + gap: 0.25rem; 2480 + min-width: 0; 2481 + flex: 1; 2482 + } 2483 + 2484 + .token-name { 2485 + font-family: monospace; 2486 + font-size: 0.9rem; 2487 + color: #e8e8e8; 2488 + white-space: nowrap; 2489 + overflow: hidden; 2490 + text-overflow: ellipsis; 2491 + } 2492 + 2493 + .token-meta { 2494 + font-size: 0.8rem; 2495 + color: #666; 2496 + } 2497 + 2498 + .revoke-btn { 2499 + padding: 0.4rem 0.75rem; 2500 + background: transparent; 2501 + border: 1px solid #4a2020; 2502 + border-radius: 4px; 2503 + color: #ff6b6b; 2504 + font-size: 0.85rem; 2505 + cursor: pointer; 2506 + transition: all 0.2s; 2507 + width: auto; 2508 + flex-shrink: 0; 2509 + } 2510 + 2511 + .revoke-btn:hover:not(:disabled) { 2512 + background: rgba(255, 107, 107, 0.1); 2513 + border-color: #ff6b6b; 2514 + } 2515 + 2516 + .revoke-btn:disabled { 2517 + opacity: 0.5; 2518 + cursor: not-allowed; 2519 + } 2520 + 2521 + .token-name-input { 2522 + padding: 0.5rem 0.75rem; 2523 + background: #0a0a0a; 2524 + border: 1px solid #333; 2525 + border-radius: 4px; 2526 + color: white; 2527 + font-size: 0.9rem; 2528 + font-family: inherit; 2529 + min-width: 150px; 2530 + } 2531 + 2532 + .token-name-input:focus { 2533 + outline: none; 2534 + border-color: var(--accent); 2535 + } 2536 + 2537 + .token-name-input:disabled { 2538 + opacity: 0.5; 2539 + cursor: not-allowed; 2540 + } 2541 + 2542 + .loading-tokens { 2543 + font-size: 0.9rem; 2544 + color: #666; 2545 + margin: 0; 2111 2546 } 2112 2547 </style>
+274
scripts/plyr.py
··· 1 + #!/usr/bin/env -S uv run --script 2 + # /// script 3 + # requires-python = ">=3.11" 4 + # dependencies = [ 5 + # "cyclopts>=3.0", 6 + # "httpx>=0.27", 7 + # "pydantic-settings>=2.0", 8 + # "rich>=13.0", 9 + # ] 10 + # /// 11 + """ 12 + plyr.fm CLI - upload and download tracks programmatically. 13 + 14 + setup: 15 + 1. create a developer token at plyr.fm/portal -> "your data" -> "developer tokens" 16 + 2. export PLYR_TOKEN="your_token_here" 17 + 18 + usage: 19 + uv run scripts/plyr.py list 20 + uv run scripts/plyr.py upload track.mp3 "My Track" --album "My Album" 21 + uv run scripts/plyr.py download 42 -o my-track.mp3 22 + uv run scripts/plyr.py delete 42 23 + 24 + environments (defaults to localhost:8001): 25 + PLYR_API_URL=https://api-stg.plyr.fm uv run scripts/plyr.py list # staging 26 + PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py list # production 27 + """ 28 + 29 + import json 30 + import sys 31 + from pathlib import Path 32 + from typing import Annotated 33 + 34 + import httpx 35 + from cyclopts import App, Parameter 36 + from pydantic import Field 37 + from pydantic_settings import BaseSettings, SettingsConfigDict 38 + from rich.console import Console 39 + from rich.table import Table 40 + 41 + console = Console() 42 + 43 + 44 + class Settings(BaseSettings): 45 + """plyr.fm CLI configuration. 46 + 47 + override api_url for different environments: 48 + PLYR_API_URL=http://localhost:8001 # local dev (default) 49 + PLYR_API_URL=https://api-stg.plyr.fm # staging 50 + PLYR_API_URL=https://api.plyr.fm # production 51 + """ 52 + 53 + model_config = SettingsConfigDict( 54 + env_prefix="PLYR_", env_file=".env", extra="ignore" 55 + ) 56 + 57 + token: str | None = Field(default=None, description="API token") 58 + api_url: str = Field(default="http://localhost:8001", description="API base URL") 59 + 60 + @property 61 + def headers(self) -> dict[str, str]: 62 + if not self.token: 63 + console.print("[red]error:[/] PLYR_TOKEN not set") 64 + console.print("create a token at plyr.fm/portal -> 'developer tokens'") 65 + sys.exit(1) 66 + return {"Authorization": f"Bearer {self.token}"} 67 + 68 + 69 + settings = Settings() 70 + app = App(help="plyr.fm CLI - upload and download tracks") 71 + 72 + 73 + @app.command 74 + def upload( 75 + file: Annotated[Path, Parameter(help="audio file to upload")], 76 + title: Annotated[str, Parameter(help="track title")], 77 + album: Annotated[str | None, Parameter(help="album name")] = None, 78 + ) -> None: 79 + """upload a track to plyr.fm.""" 80 + if not file.exists(): 81 + console.print(f"[red]error:[/] file not found: {file}") 82 + sys.exit(1) 83 + 84 + with console.status("uploading..."): 85 + with open(file, "rb") as f: 86 + files = {"file": (file.name, f)} 87 + data = {"title": title} 88 + if album: 89 + data["album"] = album 90 + 91 + response = httpx.post( 92 + f"{settings.api_url}/tracks/", 93 + headers=settings.headers, 94 + files=files, 95 + data=data, 96 + timeout=120.0, 97 + ) 98 + 99 + if response.status_code == 401: 100 + console.print("[red]error:[/] invalid or expired token") 101 + sys.exit(1) 102 + 103 + if response.status_code == 403: 104 + detail = response.json().get("detail", "") 105 + if "artist_profile_required" in detail: 106 + console.print("[red]error:[/] create an artist profile first at plyr.fm") 107 + elif "scope_upgrade_required" in detail: 108 + console.print("[red]error:[/] log out and back in, then create a new token") 109 + else: 110 + console.print(f"[red]error:[/] forbidden - {detail}") 111 + sys.exit(1) 112 + 113 + response.raise_for_status() 114 + upload_data = response.json() 115 + upload_id = upload_data.get("upload_id") 116 + 117 + if not upload_id: 118 + console.print(f"[green]done:[/] {response.json()}") 119 + return 120 + 121 + # poll for completion 122 + console.print(f"processing: {upload_id}") 123 + with httpx.stream( 124 + "GET", 125 + f"{settings.api_url}/tracks/uploads/{upload_id}/progress", 126 + headers=settings.headers, 127 + timeout=300.0, 128 + ) as sse: 129 + for line in sse.iter_lines(): 130 + if line.startswith("data: "): 131 + data = json.loads(line[6:]) 132 + status = data.get("status") 133 + 134 + if status == "completed": 135 + track_id = data.get("track_id") 136 + console.print(f"[green]uploaded:[/] track {track_id}") 137 + return 138 + elif status == "failed": 139 + error = data.get("error", "unknown error") 140 + console.print(f"[red]failed:[/] {error}") 141 + sys.exit(1) 142 + 143 + 144 + @app.command 145 + def download( 146 + track_id: Annotated[int, Parameter(help="track ID to download")], 147 + output: Annotated[ 148 + Path | None, Parameter(name=["--output", "-o"], help="output file") 149 + ] = None, 150 + ) -> None: 151 + """download a track from plyr.fm.""" 152 + # get track info first 153 + with console.status("fetching track info..."): 154 + info_response = httpx.get( 155 + f"{settings.api_url}/tracks/{track_id}", 156 + headers=settings.headers, 157 + timeout=30.0, 158 + ) 159 + 160 + if info_response.status_code == 404: 161 + console.print(f"[red]error:[/] track {track_id} not found") 162 + sys.exit(1) 163 + 164 + info_response.raise_for_status() 165 + track = info_response.json() 166 + 167 + # determine output filename 168 + if output is None: 169 + # use track title + extension from file_type 170 + ext = track.get("file_type", "mp3") 171 + safe_title = "".join( 172 + c if c.isalnum() or c in " -_" else "" for c in track["title"] 173 + ) 174 + output = Path(f"{safe_title}.{ext}") 175 + 176 + # download audio 177 + with console.status(f"downloading {track['title']}..."): 178 + audio_response = httpx.get( 179 + f"{settings.api_url}/audio/{track['file_id']}", 180 + headers=settings.headers, 181 + follow_redirects=True, 182 + timeout=300.0, 183 + ) 184 + 185 + audio_response.raise_for_status() 186 + 187 + output.write_bytes(audio_response.content) 188 + size_mb = len(audio_response.content) / 1024 / 1024 189 + console.print(f"[green]saved:[/] {output} ({size_mb:.1f} MB)") 190 + 191 + 192 + @app.command(name="list") 193 + def list_tracks( 194 + limit: Annotated[int, Parameter(help="max tracks to show")] = 20, 195 + ) -> None: 196 + """list your tracks.""" 197 + with console.status("fetching tracks..."): 198 + response = httpx.get( 199 + f"{settings.api_url}/tracks/", 200 + headers=settings.headers, 201 + timeout=30.0, 202 + ) 203 + 204 + response.raise_for_status() 205 + tracks = response.json().get("tracks", []) 206 + 207 + if not tracks: 208 + console.print("no tracks found") 209 + return 210 + 211 + table = Table(title="your tracks") 212 + table.add_column("ID", style="cyan") 213 + table.add_column("title") 214 + table.add_column("album") 215 + table.add_column("plays", justify="right") 216 + 217 + for track in tracks[:limit]: 218 + album = track.get("album") 219 + album_name = album.get("title") if isinstance(album, dict) else (album or "-") 220 + table.add_row( 221 + str(track["id"]), 222 + track["title"], 223 + album_name, 224 + str(track.get("play_count", 0)), 225 + ) 226 + 227 + console.print(table) 228 + 229 + 230 + @app.command 231 + def delete( 232 + track_id: Annotated[int, Parameter(help="track ID to delete")], 233 + yes: Annotated[ 234 + bool, Parameter(name=["--yes", "-y"], help="skip confirmation") 235 + ] = False, 236 + ) -> None: 237 + """delete a track.""" 238 + # get track info first 239 + with console.status("fetching track info..."): 240 + info_response = httpx.get( 241 + f"{settings.api_url}/tracks/{track_id}", 242 + headers=settings.headers, 243 + timeout=30.0, 244 + ) 245 + 246 + if info_response.status_code == 404: 247 + console.print(f"[red]error:[/] track {track_id} not found") 248 + sys.exit(1) 249 + 250 + info_response.raise_for_status() 251 + track = info_response.json() 252 + 253 + if not yes: 254 + console.print(f"delete '{track['title']}'? [y/N] ", end="") 255 + if input().lower() != "y": 256 + console.print("cancelled") 257 + return 258 + 259 + response = httpx.delete( 260 + f"{settings.api_url}/tracks/{track_id}", 261 + headers=settings.headers, 262 + timeout=30.0, 263 + ) 264 + 265 + if response.status_code == 404: 266 + console.print(f"[red]error:[/] track {track_id} not found") 267 + sys.exit(1) 268 + 269 + response.raise_for_status() 270 + console.print(f"[green]deleted:[/] {track['title']}") 271 + 272 + 273 + if __name__ == "__main__": 274 + app()