feat: add supporter badges for atprotofans integration (phase 1) (#627)

- add SupporterBadge component with heart icon styling
- add validateSupporter API call to artist page when viewer is logged in
- call atprotofans API directly from frontend (public endpoint)
- use broker DID for signer parameter
- only show badge when:
- viewer is authenticated
- artist has support_url: 'atprotofans'
- viewer is not the artist themselves
- validation returns valid: true
- update research doc with implementation status and correct API usage
- add new research doc for supporter-gated content architecture (R2 presigned URLs)

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

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

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub 19e24744 958cac78

+29 -20
docs/research/2025-12-20-atprotofans-paywall-integration.md
··· 91 91 92 92 ## implementation phases 93 93 94 - ### phase 1: read-only validation (week 1) 94 + ### phase 1: read-only validation (week 1) - IMPLEMENTED 95 95 96 96 **goal**: show supporter badges, no platform registration required 97 97 98 - 1. **add validateSupporter calls to artist page** 98 + **status**: completed 2025-12-20 99 + 100 + 1. **add validateSupporter calls to artist page** ✓ 99 101 ```typescript 100 102 // when viewing artist page, if viewer is logged in: 101 - const validation = await fetch( 102 - `https://atprotofans.com/xrpc/com.atprotofans.validateSupporter` + 103 - `?supporter=${viewer.did}&subject=${artist.did}&signer=${artist.did}` 104 - ); 105 - if (validation.valid) { 106 - // show "supporter" badge 103 + const ATPROTOFANS_BROKER_DID = 'did:plc:7ewx3bksukdk6a4vycoykhhw'; 104 + const url = new URL('https://atprotofans.com/xrpc/com.atprotofans.validateSupporter'); 105 + url.searchParams.set('supporter', auth.user.did); 106 + url.searchParams.set('subject', artist.did); 107 + url.searchParams.set('signer', ATPROTOFANS_BROKER_DID); 108 + 109 + const response = await fetch(url.toString()); 110 + if (response.ok) { 111 + const data = await response.json(); 112 + isSupporter = data.valid === true; 107 113 } 108 114 ``` 109 115 110 - 2. **cache validation results** 111 - - redis cache with 5-minute TTL 112 - - key: `atprotofans:supporter:{viewer_did}:{artist_did}` 116 + 2. **cache validation results** - deferred 117 + - frontend calls atprotofans directly (no backend cache needed initially) 118 + - can add redis cache later if rate limiting becomes an issue 113 119 114 - 3. **display supporter badge on profile** 115 - - similar to verified badge styling 116 - - tooltip: "supports this artist via atprotofans" 120 + 3. **display supporter badge on profile** ✓ 121 + - heart icon with "supporter" label 122 + - tooltip: "you support this artist via atprotofans" 123 + - only shown when logged-in viewer is a supporter 117 124 118 - **frontend changes:** 119 - - `+page.svelte` (artist): call validation on mount if viewer logged in 120 - - new `SupporterBadge.svelte` component 125 + **files changed:** 126 + - `frontend/src/routes/u/[handle]/+page.svelte` - added validation logic 127 + - `frontend/src/lib/components/SupporterBadge.svelte` - new component 121 128 122 - **backend changes:** 123 - - new endpoint: `GET /artists/{did}/supporter-status?viewer_did={did}` 124 - - or: call atprotofans directly from frontend (simpler, public endpoint) 129 + **implementation notes:** 130 + - calls atprotofans directly from frontend (public endpoint, no auth needed) 131 + - uses broker DID `did:plc:7ewx3bksukdk6a4vycoykhhw` as signer 132 + - only checks if artist has `support_url: 'atprotofans'` 133 + - doesn't show on your own profile 125 134 126 135 ### phase 2: platform registration (week 2) 127 136
+288
docs/research/2025-12-20-supporter-gated-content-architecture.md
··· 1 + # research: supporter-gated content architecture 2 + 3 + **date**: 2025-12-20 4 + **question**: how do we prevent direct R2 access to paywalled audio content? 5 + 6 + ## the problem 7 + 8 + current architecture: 9 + ``` 10 + upload → R2 (public bucket) → `https://pub-xxx.r2.dev/audio/{file_id}.mp3` 11 + 12 + anyone with URL can access 13 + ``` 14 + 15 + if we add supporter-gating at the API level, users can bypass it: 16 + 1. view network requests when a supporter plays a track 17 + 2. extract the R2 URL 18 + 3. share it directly (or access it themselves without being a supporter) 19 + 20 + ## solution options 21 + 22 + ### option 1: private bucket + presigned URLs 23 + 24 + **architecture:** 25 + ``` 26 + upload → R2 (PRIVATE bucket) → not directly accessible 27 + 28 + backend generates presigned URL on demand 29 + 30 + supporter validated → 1-hour presigned URL returned 31 + not supporter → 402 "become a supporter" 32 + ``` 33 + 34 + **how it works:** 35 + ```python 36 + # backend/storage/r2.py 37 + async def get_presigned_url(self, file_id: str, expires_in: int = 3600) -> str: 38 + """generate presigned URL for private bucket access.""" 39 + async with self.async_session.client(...) as client: 40 + return await client.generate_presigned_url( 41 + 'get_object', 42 + Params={'Bucket': self.private_audio_bucket, 'Key': f'audio/{file_id}.mp3'}, 43 + ExpiresIn=expires_in 44 + ) 45 + ``` 46 + 47 + **pros:** 48 + - strong access control - no way to bypass 49 + - URLs expire automatically 50 + - standard S3 pattern, well-supported 51 + 52 + **cons:** 53 + - presigned URLs use S3 API domain (`<account>.r2.cloudflarestorage.com`), not CDN 54 + - no Cloudflare caching (every request goes to origin) 55 + - potentially higher latency and costs 56 + - need separate bucket for gated content 57 + 58 + **cost impact:** 59 + - R2 egress is free, but no CDN caching means more origin requests 60 + - Class A operations (PUT, LIST) cost more than Class B (GET) 61 + 62 + ### option 2: dual bucket (public + private) 63 + 64 + **architecture:** 65 + ``` 66 + public tracks → audio-public bucket → direct CDN URLs 67 + gated tracks → audio-private bucket → presigned URLs only 68 + ``` 69 + 70 + **upload flow:** 71 + ```python 72 + if track.required_support_tier: 73 + bucket = self.private_audio_bucket 74 + else: 75 + bucket = self.public_audio_bucket 76 + ``` 77 + 78 + **pros:** 79 + - public content stays fast (CDN cached) 80 + - only gated content needs presigned URLs 81 + - gradual migration possible 82 + 83 + **cons:** 84 + - complexity of managing two buckets 85 + - track tier change = file move between buckets 86 + - still no CDN for gated content 87 + 88 + ### option 3: cloudflare access + workers (enterprise-ish) 89 + 90 + **architecture:** 91 + ``` 92 + all audio → R2 bucket (with custom domain) 93 + 94 + Cloudflare Worker validates JWT/supporter status 95 + 96 + pass → serve from R2 97 + fail → 402 response 98 + ``` 99 + 100 + **how it works:** 101 + - custom domain on R2 bucket (e.g., `audio.plyr.fm`) 102 + - Cloudflare Worker intercepts requests 103 + - worker validates supporter token from cookie/header 104 + - if valid, proxies request to R2 105 + 106 + **pros:** 107 + - CDN caching works (huge for audio streaming) 108 + - single bucket 109 + - flexible access control 110 + 111 + **cons:** 112 + - requires custom domain setup 113 + - worker invocations add latency (~1-5ms) 114 + - more infrastructure to maintain 115 + - Cloudflare Access (proper auth) requires Pro plan 116 + 117 + ### option 4: short-lived tokens in URL path 118 + 119 + **architecture:** 120 + ``` 121 + backend generates: /audio/{token}/{file_id} 122 + 123 + token = sign({file_id, expires, user_did}) 124 + 125 + frontend plays URL normally 126 + 127 + if token invalid/expired → 403 128 + ``` 129 + 130 + **how it works:** 131 + ```python 132 + # generate token 133 + token = jwt.encode({ 134 + 'file_id': file_id, 135 + 'exp': time.time() + 3600, 136 + 'sub': viewer_did 137 + }, SECRET_KEY) 138 + 139 + url = f"https://api.plyr.fm/audio/{token}/{file_id}" 140 + 141 + # validate on request 142 + @router.get("/audio/{token}/{file_id}") 143 + async def stream_gated_audio(token: str, file_id: str): 144 + payload = jwt.decode(token, SECRET_KEY) 145 + if payload['file_id'] != file_id: 146 + raise HTTPException(403) 147 + if payload['exp'] < time.time(): 148 + raise HTTPException(403, "URL expired") 149 + 150 + # proxy to R2 or redirect to presigned URL 151 + return RedirectResponse(await storage.get_presigned_url(file_id)) 152 + ``` 153 + 154 + **pros:** 155 + - works with existing backend 156 + - no new infrastructure 157 + - token validates both file and user 158 + 159 + **cons:** 160 + - every request hits backend (no CDN) 161 + - sharing URL shares access (until expiry) 162 + - backend becomes bottleneck for streaming 163 + 164 + ## recommendation 165 + 166 + **for MVP (phase 1-2)**: option 2 (dual bucket) 167 + 168 + rationale: 169 + - public content (majority) stays fast 170 + - gated content works correctly, just slightly slower 171 + - simple to implement with existing boto3 code 172 + - no new infrastructure needed 173 + 174 + **for scale (phase 3+)**: option 3 (workers) 175 + 176 + rationale: 177 + - CDN caching for all content 178 + - better streaming performance 179 + - more flexible access control 180 + - worth the complexity at scale 181 + 182 + ## implementation plan for dual bucket 183 + 184 + ### 1. create private bucket 185 + 186 + ```bash 187 + # create private audio bucket (no public access) 188 + wrangler r2 bucket create audio-private-dev 189 + wrangler r2 bucket create audio-private-staging 190 + wrangler r2 bucket create audio-private-prod 191 + ``` 192 + 193 + ### 2. add config 194 + 195 + ```python 196 + # config.py 197 + r2_private_audio_bucket: str = Field( 198 + default="", 199 + validation_alias="R2_PRIVATE_AUDIO_BUCKET", 200 + description="R2 private bucket for supporter-gated audio", 201 + ) 202 + ``` 203 + 204 + ### 3. update R2Storage 205 + 206 + ```python 207 + # storage/r2.py 208 + async def save_gated(self, file: BinaryIO, filename: str, ...) -> str: 209 + """save to private bucket for gated content.""" 210 + # same as save() but uses private_audio_bucket 211 + 212 + async def get_presigned_url(self, file_id: str, expires_in: int = 3600) -> str: 213 + """generate presigned URL for private content.""" 214 + key = f"audio/{file_id}.mp3" # need extension from DB 215 + return self.client.generate_presigned_url( 216 + 'get_object', 217 + Params={'Bucket': self.private_audio_bucket, 'Key': key}, 218 + ExpiresIn=expires_in 219 + ) 220 + ``` 221 + 222 + ### 4. update audio endpoint 223 + 224 + ```python 225 + # api/audio.py 226 + @router.get("/{file_id}") 227 + async def stream_audio(file_id: str, session: Session | None = Depends(optional_auth)): 228 + track = await get_track_by_file_id(file_id) 229 + 230 + if track.required_support_tier: 231 + # gated content - validate supporter status 232 + if not session: 233 + raise HTTPException(401, "login required") 234 + 235 + is_supporter = await validate_supporter( 236 + supporter=session.did, 237 + subject=track.artist_did 238 + ) 239 + 240 + if not is_supporter: 241 + raise HTTPException(402, "supporter access required") 242 + 243 + # return presigned URL for private bucket 244 + url = await storage.get_presigned_url(file_id) 245 + return RedirectResponse(url) 246 + 247 + # public content - use CDN URL 248 + return RedirectResponse(track.r2_url) 249 + ``` 250 + 251 + ### 5. upload flow change 252 + 253 + ```python 254 + # api/tracks.py (in upload handler) 255 + if required_support_tier: 256 + file_id = await storage.save_gated(file, filename) 257 + # no public URL - will be generated on demand 258 + r2_url = None 259 + else: 260 + file_id = await storage.save(file, filename) 261 + r2_url = f"{settings.storage.r2_public_bucket_url}/audio/{file_id}{ext}" 262 + ``` 263 + 264 + ## open questions 265 + 266 + 1. **what about tier changes?** 267 + - if artist makes public track → gated: need to move file to private bucket 268 + - if gated → public: move to public bucket 269 + - or: store everything in private, just serve presigned URLs for everything (simpler but slower) 270 + 271 + 2. **presigned URL expiry for long audio?** 272 + - 1 hour default should be plenty for any track 273 + - frontend can request new URL if needed mid-playback 274 + 275 + 3. **should we cache presigned URLs?** 276 + - could cache for 30 minutes to reduce generation overhead 277 + - but then revocation is delayed 278 + 279 + 4. **offline mode interaction?** 280 + - supporters could download gated tracks for offline 281 + - presigned URL works for initial download 282 + - cached locally, no expiry concern 283 + 284 + ## references 285 + 286 + - [Cloudflare R2 presigned URLs docs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/) 287 + - [Cloudflare R2 + Access protection](https://developers.cloudflare.com/r2/tutorials/cloudflare-access/) 288 + - boto3 `generate_presigned_url()` - already available in our client
+40
frontend/src/lib/components/SupporterBadge.svelte
··· 1 + <script lang="ts"> 2 + /** 3 + * displays a badge indicating the viewer supports the artist via atprotofans. 4 + * only shown when the logged-in user has validated supporter status. 5 + */ 6 + </script> 7 + 8 + <span class="supporter-badge" title="you support this artist via atprotofans"> 9 + <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> 10 + <path 11 + d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" 12 + /> 13 + </svg> 14 + <span class="label">supporter</span> 15 + </span> 16 + 17 + <style> 18 + .supporter-badge { 19 + display: inline-flex; 20 + align-items: center; 21 + gap: 0.3rem; 22 + padding: 0.2rem 0.5rem; 23 + background: color-mix(in srgb, var(--accent) 15%, transparent); 24 + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); 25 + border-radius: 4px; 26 + color: var(--accent); 27 + font-size: 0.75rem; 28 + font-weight: 500; 29 + text-transform: lowercase; 30 + white-space: nowrap; 31 + } 32 + 33 + .supporter-badge svg { 34 + flex-shrink: 0; 35 + } 36 + 37 + .label { 38 + line-height: 1; 39 + } 40 + </style>
+62 -5
frontend/src/routes/u/[handle]/+page.svelte
··· 8 8 import ShareButton from '$lib/components/ShareButton.svelte'; 9 9 import Header from '$lib/components/Header.svelte'; 10 10 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 11 + import SupporterBadge from '$lib/components/SupporterBadge.svelte'; 11 12 import { checkImageSensitive } from '$lib/moderation.svelte'; 12 13 import { player } from '$lib/player.svelte'; 13 14 import { queue } from '$lib/queue.svelte'; ··· 15 16 import { fetchLikedTracks, fetchUserLikes } from '$lib/tracks.svelte'; 16 17 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 17 18 import type { PageData } from './$types'; 19 + 20 + // atprotofans broker DID for validateSupporter calls 21 + const ATPROTOFANS_BROKER_DID = 'did:plc:7ewx3bksukdk6a4vycoykhhw'; 18 22 19 23 // receive server-loaded data 20 24 let { data }: { data: PageData } = $props(); ··· 67 71 // public playlists for collections section 68 72 let publicPlaylists = $state<Playlist[]>([]); 69 73 74 + // supporter status - true if logged-in viewer supports this artist via atprotofans 75 + let isSupporter = $state(false); 76 + 70 77 // track which artist we've loaded data for to detect navigation 71 78 let loadedForDid = $state<string | null>(null); 72 79 ··· 130 137 } 131 138 } 132 139 140 + /** 141 + * check if the logged-in viewer supports this artist via atprotofans. 142 + * only called when: 143 + * 1. viewer is authenticated 144 + * 2. artist has atprotofans support enabled 145 + * 3. viewer is not the artist themselves 146 + */ 147 + async function checkSupporterStatus() { 148 + // reset state 149 + isSupporter = false; 150 + 151 + // only check if viewer is logged in 152 + if (!auth.isAuthenticated || !auth.user?.did) return; 153 + 154 + // only check if artist has atprotofans enabled 155 + if (artist?.support_url !== 'atprotofans') return; 156 + 157 + // don't show badge on your own profile 158 + if (auth.user.did === artist.did) return; 159 + 160 + try { 161 + const url = new URL('https://atprotofans.com/xrpc/com.atprotofans.validateSupporter'); 162 + url.searchParams.set('supporter', auth.user.did); 163 + url.searchParams.set('subject', artist.did); 164 + url.searchParams.set('signer', ATPROTOFANS_BROKER_DID); 165 + 166 + const response = await fetch(url.toString()); 167 + if (response.ok) { 168 + const data = await response.json(); 169 + isSupporter = data.valid === true; 170 + } 171 + } catch (_e) { 172 + // silently fail - supporter badge is optional enhancement 173 + console.error('failed to check supporter status:', _e); 174 + } 175 + } 176 + 133 177 // reload data when navigating between artist pages 134 178 // watch data.artist?.did (from server) not artist?.did (local derived) 135 179 $effect(() => { ··· 143 187 tracksHydrated = false; 144 188 likedTracksCount = null; 145 189 publicPlaylists = []; 190 + isSupporter = false; 146 191 147 192 // sync tracks and pagination from server data 148 193 tracks = data.tracks ?? []; ··· 158 203 void hydrateTracksWithLikes(); 159 204 void loadLikedTracksCount(); 160 205 void loadPublicPlaylists(); 206 + void checkSupporterStatus(); 161 207 } 162 208 }); 163 209 ··· 310 356 <div class="artist-details"> 311 357 <div class="artist-info"> 312 358 <h1>{artist.display_name}</h1> 313 - <a href="https://bsky.app/profile/{artist.handle}" target="_blank" rel="noopener" class="handle"> 314 - @{artist.handle} 315 - </a> 359 + <div class="handle-row"> 360 + <a href="https://bsky.app/profile/{artist.handle}" target="_blank" rel="noopener" class="handle"> 361 + @{artist.handle} 362 + </a> 363 + {#if isSupporter} 364 + <SupporterBadge /> 365 + {/if} 366 + </div> 316 367 {#if artist.bio} 317 368 <p class="bio">{artist.bio}</p> 318 369 {/if} ··· 636 687 hyphens: auto; 637 688 } 638 689 690 + .handle-row { 691 + display: flex; 692 + align-items: center; 693 + gap: 0.75rem; 694 + flex-wrap: wrap; 695 + margin-bottom: 1rem; 696 + } 697 + 639 698 .handle { 640 699 color: var(--text-tertiary); 641 700 font-size: 1.1rem; 642 - margin: 0 0 1rem 0; 643 701 text-decoration: none; 644 702 transition: color 0.2s; 645 - display: inline-block; 646 703 } 647 704 648 705 .handle:hover {