feat: add atprotofans support link mode (#562)

* feat: add atprotofans support link mode

adds three support link modes for artist profiles:
- none: no support link displayed
- atprotofans: links to atprotofans.com/profile/{handle}
- custom: user-provided https:// URL (existing behavior)

backend:
- validate support_url accepts 'atprotofans' magic value
- reject non-https URLs for custom links
- add tests for all three modes

frontend:
- portal page: radio button selector for support link mode
- artist profile: compute actual URL from mode + handle

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

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

* fix: validate atprotofans eligibility before selection

checks user's PDS for com.atprotofans.profile/self record with
acceptingSupporters=true before allowing atprotofans selection.

- client-side check on portal load (no backend sprawl)
- shows "set up" link when not eligible
- shows "ready" when eligible
- disables option when ineligible

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

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

* fix: use ATProto SDK for proper handle/PDS resolution

- add @atproto/api SDK to frontend for proper ATProto operations
- fix portal atprotofans eligibility check to resolve DID to PDS first
- fix backend handles.py to use AsyncIdResolver instead of bsky.social
- fix frontend error.svelte to use SDK for handle resolution

the issue was hardcoded bsky.social URLs which don't work for users
on self-hosted PDS instances. now we properly resolve DIDs via
plc.directory and query the user's actual PDS.

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

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

* fix: show 'profile ready' as link to atprotofans profile

when eligible, the status now shows "profile ready" as a clickable
link to the user's actual atprotofans profile page.

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

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

* fix: correct atprotofans URL to use /u/{did} not /profile/{handle}

🤖 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 c7be970b dbfc7b70

Changed files
+335 -54
backend
src
backend
_internal
atproto
api
tests
frontend
+12 -15
backend/src/backend/_internal/atproto/handles.py
··· 4 4 from typing import Any 5 5 6 6 import httpx 7 + from atproto import AsyncIdResolver 7 8 8 9 logger = logging.getLogger(__name__) 10 + 11 + # shared resolver instance for DID/handle resolution 12 + _resolver = AsyncIdResolver() 9 13 10 14 11 15 async def resolve_handle(handle: str) -> dict[str, Any] | None: ··· 21 25 handle = handle.lstrip("@") 22 26 23 27 try: 24 - async with httpx.AsyncClient() as client: 25 - # resolve handle to DID 26 - did_response = await client.get( 27 - "https://bsky.social/xrpc/com.atproto.identity.resolveHandle", 28 - params={"handle": handle}, 29 - timeout=5.0, 30 - ) 28 + # use ATProto SDK for proper handle resolution (works with any PDS) 29 + did = await _resolver.handle.resolve(handle) 31 30 32 - if did_response.status_code != 200: 33 - logger.warning( 34 - f"failed to resolve handle {handle}: {did_response.status_code}" 35 - ) 36 - return None 31 + if not did: 32 + logger.warning(f"failed to resolve handle {handle}: no DID found") 33 + return None 37 34 38 - did = did_response.json()["did"] 39 - 40 - # fetch profile info 35 + # fetch profile info from Bluesky appview (for display name/avatar) 36 + # this is acceptable since we're fetching Bluesky profile data specifically 37 + async with httpx.AsyncClient() as client: 41 38 profile_response = await client.get( 42 39 "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile", 43 40 params={"actor": did},
+18 -1
backend/src/backend/api/preferences.py
··· 3 3 from typing import Annotated 4 4 5 5 from fastapi import APIRouter, Depends 6 - from pydantic import BaseModel 6 + from pydantic import BaseModel, field_validator 7 7 from sqlalchemy import select 8 8 from sqlalchemy.ext.asyncio import AsyncSession 9 9 ··· 13 13 from backend.utilities.tags import DEFAULT_HIDDEN_TAGS 14 14 15 15 router = APIRouter(prefix="/preferences", tags=["preferences"]) 16 + 17 + # magic value for atprotofans support link mode 18 + ATPROTOFANS_MODE = "atprotofans" 16 19 17 20 18 21 class PreferencesResponse(BaseModel): ··· 41 44 show_sensitive_artwork: bool | None = None 42 45 show_liked_on_profile: bool | None = None 43 46 support_url: str | None = None 47 + 48 + @field_validator("support_url", mode="before") 49 + @classmethod 50 + def validate_support_url(cls, v: str | None) -> str | None: 51 + """validate support url: empty, 'atprotofans', or https:// URL.""" 52 + if v is None or v == "": 53 + return v # let update logic handle clearing 54 + if v == ATPROTOFANS_MODE: 55 + return v 56 + if not v.startswith("https://"): 57 + raise ValueError( 58 + "support link must be 'atprotofans' or start with https://" 59 + ) 60 + return v 44 61 45 62 46 63 def _has_teal_scope(session: Session) -> bool:
+36
backend/tests/api/test_preferences.py
··· 221 221 # support_url should still be set 222 222 assert data["support_url"] == "https://patreon.com/testartist" 223 223 assert data["auto_advance"] is False 224 + 225 + 226 + async def test_set_support_url_atprotofans( 227 + client_no_teal: AsyncClient, 228 + ): 229 + """should accept 'atprotofans' as a valid support_url value.""" 230 + response = await client_no_teal.post( 231 + "/preferences/", 232 + json={"support_url": "atprotofans"}, 233 + ) 234 + assert response.status_code == 200 235 + 236 + data = response.json() 237 + assert data["support_url"] == "atprotofans" 238 + 239 + 240 + async def test_support_url_rejects_http( 241 + client_no_teal: AsyncClient, 242 + ): 243 + """should reject http:// URLs (only https:// or 'atprotofans' allowed).""" 244 + response = await client_no_teal.post( 245 + "/preferences/", 246 + json={"support_url": "http://insecure.com"}, 247 + ) 248 + assert response.status_code == 422 # validation error 249 + 250 + 251 + async def test_support_url_rejects_invalid_strings( 252 + client_no_teal: AsyncClient, 253 + ): 254 + """should reject strings that aren't 'atprotofans' or https:// URLs.""" 255 + response = await client_no_teal.post( 256 + "/preferences/", 257 + json={"support_url": "random-string"}, 258 + ) 259 + assert response.status_code == 422 # validation error
frontend/bun.lockb

This is a binary file and will not be displayed.

+3
frontend/package.json
··· 27 27 "svelte-check": "^4.3.2", 28 28 "typescript": "^5.9.2", 29 29 "vite": "^7.1.7" 30 + }, 31 + "dependencies": { 32 + "@atproto/api": "^0.18.7" 30 33 } 31 34 }
+242 -21
frontend/src/routes/portal/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import { invalidateAll, replaceState } from '$app/navigation'; 4 + import { AtpAgent } from '@atproto/api'; 4 5 import Header from '$lib/components/Header.svelte'; 5 6 import HandleSearch from '$lib/components/HandleSearch.svelte'; 6 7 import AlbumSelect from '$lib/components/AlbumSelect.svelte'; ··· 33 34 let displayName = $state(''); 34 35 let bio = $state(''); 35 36 let avatarUrl = $state(''); 36 - let supportUrl = $state(''); 37 + // support link mode: 'none' | 'atprotofans' | 'custom' 38 + let supportLinkMode = $state<'none' | 'atprotofans' | 'custom'>('none'); 39 + let customSupportUrl = $state(''); 37 40 let savingProfile = $state(false); 38 - let savingSupportUrl = $state(false); 41 + // atprotofans eligibility - checked on mount 42 + let atprotofansEligible = $state(false); 43 + let checkingAtprotofans = $state(false); 39 44 40 45 // album management state 41 46 let albums = $state<AlbumSummary[]>([]); ··· 129 134 } 130 135 } 131 136 137 + async function checkAtprotofansEligibility() { 138 + if (!auth.user?.did) return; 139 + checkingAtprotofans = true; 140 + try { 141 + // resolve DID to find user's PDS (com.atprotofans.profile isn't indexed by Bluesky appview) 142 + const didDoc = await fetch(`https://plc.directory/${auth.user.did}`).then((r) => r.json()); 143 + const pdsService = didDoc?.service?.find( 144 + (s: { id: string }) => s.id === '#atproto_pds' 145 + ); 146 + const pdsUrl = pdsService?.serviceEndpoint; 147 + if (!pdsUrl) { 148 + atprotofansEligible = false; 149 + return; 150 + } 151 + 152 + // use SDK agent pointed at user's PDS to fetch the record 153 + const agent = new AtpAgent({ service: pdsUrl }); 154 + const response = await agent.com.atproto.repo.getRecord({ 155 + repo: auth.user.did, 156 + collection: 'com.atprotofans.profile', 157 + rkey: 'self' 158 + }); 159 + const value = response.data.value as { acceptingSupporters?: boolean } | undefined; 160 + atprotofansEligible = value?.acceptingSupporters === true; 161 + } catch (_e) { 162 + // record doesn't exist or other error - not eligible 163 + atprotofansEligible = false; 164 + } finally { 165 + checkingAtprotofans = false; 166 + } 167 + } 168 + 132 169 async function loadArtistProfile() { 133 170 try { 134 171 const [artistRes, prefsRes] = await Promise.all([ 135 172 fetch(`${API_URL}/artists/me`, { credentials: 'include' }), 136 - fetch(`${API_URL}/preferences/`, { credentials: 'include' }) 173 + fetch(`${API_URL}/preferences/`, { credentials: 'include' }), 174 + checkAtprotofansEligibility() 137 175 ]); 138 176 139 177 if (artistRes.ok) { ··· 145 183 146 184 if (prefsRes.ok) { 147 185 const prefs = await prefsRes.json(); 148 - supportUrl = prefs.support_url || ''; 186 + // parse support_url into mode + custom URL 187 + const url = prefs.support_url || ''; 188 + if (!url) { 189 + supportLinkMode = 'none'; 190 + customSupportUrl = ''; 191 + } else if (url === 'atprotofans') { 192 + supportLinkMode = 'atprotofans'; 193 + customSupportUrl = ''; 194 + } else { 195 + supportLinkMode = 'custom'; 196 + customSupportUrl = url; 197 + } 149 198 } 150 199 } catch (_e) { 151 200 console.error('failed to load artist profile:', _e); ··· 189 238 savingProfile = true; 190 239 191 240 try { 192 - // validate support URL 193 - const trimmedSupportUrl = supportUrl.trim(); 194 - if (trimmedSupportUrl && !trimmedSupportUrl.startsWith('https://')) { 195 - toast.error('support link must start with https://'); 196 - savingProfile = false; 197 - return; 241 + // compute support_url value based on mode 242 + let supportUrlValue = ''; 243 + if (supportLinkMode === 'atprotofans') { 244 + supportUrlValue = 'atprotofans'; 245 + } else if (supportLinkMode === 'custom') { 246 + const trimmed = customSupportUrl.trim(); 247 + if (trimmed && !trimmed.startsWith('https://')) { 248 + toast.error('custom support link must start with https://'); 249 + savingProfile = false; 250 + return; 251 + } 252 + supportUrlValue = trimmed; 198 253 } 199 254 200 255 // save artist profile and support URL in parallel ··· 213 268 method: 'POST', 214 269 headers: { 'Content-Type': 'application/json' }, 215 270 credentials: 'include', 216 - body: JSON.stringify({ support_url: trimmedSupportUrl || '' }) 271 + body: JSON.stringify({ support_url: supportUrlValue }) 217 272 }) 218 273 ]); 219 274 ··· 527 582 {/if} 528 583 </div> 529 584 530 - <div class="form-group"> 531 - <label for="support-url">support link (optional)</label> 532 - <input 533 - id="support-url" 534 - type="url" 535 - bind:value={supportUrl} 536 - disabled={savingSupportUrl} 537 - placeholder="https://ko-fi.com/yourname" 538 - /> 539 - <p class="hint">link to Ko-fi, Patreon, or similar - shown on your profile</p> 585 + <div class="form-group support-link-group" role="group" aria-labelledby="support-link-label"> 586 + <span id="support-link-label" class="form-label">support link (optional)</span> 587 + <div class="support-options"> 588 + <label class="support-option"> 589 + <input 590 + type="radio" 591 + name="support-mode" 592 + value="none" 593 + bind:group={supportLinkMode} 594 + disabled={savingProfile} 595 + /> 596 + <span>none</span> 597 + </label> 598 + <label class="support-option" class:disabled={!atprotofansEligible && supportLinkMode !== 'atprotofans'}> 599 + <input 600 + type="radio" 601 + name="support-mode" 602 + value="atprotofans" 603 + bind:group={supportLinkMode} 604 + disabled={savingProfile || (!atprotofansEligible && supportLinkMode !== 'atprotofans')} 605 + /> 606 + <span>atprotofans</span> 607 + {#if checkingAtprotofans} 608 + <span class="support-status">checking...</span> 609 + {:else if !atprotofansEligible} 610 + <a href="https://atprotofans.com" target="_blank" rel="noopener" class="support-setup-link">set up</a> 611 + {:else} 612 + <a href="https://atprotofans.com/u/{auth.user?.did}" target="_blank" rel="noopener" class="support-status-link">profile ready</a> 613 + {/if} 614 + </label> 615 + <label class="support-option"> 616 + <input 617 + type="radio" 618 + name="support-mode" 619 + value="custom" 620 + bind:group={supportLinkMode} 621 + disabled={savingProfile} 622 + /> 623 + <span>custom link</span> 624 + </label> 625 + </div> 626 + {#if supportLinkMode === 'custom'} 627 + <input 628 + id="custom-support-url" 629 + type="url" 630 + bind:value={customSupportUrl} 631 + disabled={savingProfile} 632 + placeholder="https://ko-fi.com/yourname" 633 + class="custom-support-input" 634 + /> 635 + {/if} 636 + <p class="hint"> 637 + {#if supportLinkMode === 'atprotofans'} 638 + uses <a href="https://atprotofans.com" target="_blank" rel="noopener">atprotofans</a> for ATProto-native support 639 + {:else if supportLinkMode === 'custom'} 640 + link to Ko-fi, Patreon, or similar - shown on your profile 641 + {:else} 642 + no support link will be shown on your profile 643 + {/if} 644 + </p> 540 645 </div> 541 646 542 647 <button type="submit" disabled={savingProfile || !displayName}> ··· 1174 1279 margin-top: 0.35rem; 1175 1280 font-size: 0.75rem; 1176 1281 color: var(--text-muted); 1282 + } 1283 + 1284 + .hint a { 1285 + color: var(--accent); 1286 + text-decoration: none; 1287 + } 1288 + 1289 + .hint a:hover { 1290 + text-decoration: underline; 1291 + } 1292 + 1293 + /* support link options */ 1294 + .support-link-group .form-label { 1295 + display: block; 1296 + color: var(--text-secondary); 1297 + margin-bottom: 0.6rem; 1298 + font-size: 0.85rem; 1299 + } 1300 + 1301 + .support-options { 1302 + display: flex; 1303 + flex-direction: column; 1304 + gap: 0.5rem; 1305 + margin-bottom: 0.75rem; 1306 + } 1307 + 1308 + .support-option { 1309 + display: flex; 1310 + align-items: center; 1311 + gap: 0.5rem; 1312 + padding: 0.6rem 0.75rem; 1313 + background: var(--bg-primary); 1314 + border: 1px solid var(--border-default); 1315 + border-radius: 6px; 1316 + cursor: pointer; 1317 + transition: all 0.15s; 1318 + margin-bottom: 0; 1319 + } 1320 + 1321 + .support-option:hover { 1322 + border-color: var(--border-emphasis); 1323 + } 1324 + 1325 + .support-option:has(input:checked) { 1326 + border-color: var(--accent); 1327 + background: color-mix(in srgb, var(--accent) 8%, var(--bg-primary)); 1328 + } 1329 + 1330 + .support-option input[type='radio'] { 1331 + width: 16px; 1332 + height: 16px; 1333 + accent-color: var(--accent); 1334 + margin: 0; 1335 + } 1336 + 1337 + .support-option span { 1338 + font-size: 0.9rem; 1339 + color: var(--text-primary); 1340 + } 1341 + 1342 + .support-status { 1343 + margin-left: auto; 1344 + font-size: 0.75rem; 1345 + color: var(--text-tertiary); 1346 + } 1347 + 1348 + .support-setup-link, 1349 + .support-status-link { 1350 + margin-left: auto; 1351 + font-size: 0.75rem; 1352 + text-decoration: none; 1353 + } 1354 + 1355 + .support-setup-link { 1356 + color: var(--accent); 1357 + } 1358 + 1359 + .support-status-link { 1360 + color: var(--success, #22c55e); 1361 + } 1362 + 1363 + .support-setup-link:hover, 1364 + .support-status-link:hover { 1365 + text-decoration: underline; 1366 + } 1367 + 1368 + .support-option.disabled { 1369 + opacity: 0.5; 1370 + cursor: not-allowed; 1371 + } 1372 + 1373 + .support-option.disabled input { 1374 + cursor: not-allowed; 1375 + } 1376 + 1377 + .custom-support-input { 1378 + width: 100%; 1379 + padding: 0.6rem 0.75rem; 1380 + background: var(--bg-primary); 1381 + border: 1px solid var(--border-default); 1382 + border-radius: 4px; 1383 + color: var(--text-primary); 1384 + font-size: 0.95rem; 1385 + font-family: inherit; 1386 + transition: all 0.15s; 1387 + margin-bottom: 0.5rem; 1388 + } 1389 + 1390 + .custom-support-input:focus { 1391 + outline: none; 1392 + border-color: var(--accent); 1393 + } 1394 + 1395 + .custom-support-input:disabled { 1396 + opacity: 0.5; 1397 + cursor: not-allowed; 1177 1398 } 1178 1399 1179 1400 .avatar-preview {
+11 -13
frontend/src/routes/u/[handle]/+error.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import { page } from '$app/stores'; 4 + import { AtpAgent } from '@atproto/api'; 4 5 import { APP_NAME } from '$lib/branding'; 5 6 6 7 const status = $page.status; ··· 11 12 let blueskyUrl = $state(''); 12 13 13 14 onMount(async () => { 14 - // if this is a 404, check if the handle exists on Bluesky 15 + // if this is a 404, check if the handle exists on the ATProto network 15 16 if (status === 404 && handle) { 16 17 checkingBluesky = true; 17 18 try { 18 - // try to resolve the handle via ATProto 19 - const response = await fetch( 20 - `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${handle}` 21 - ); 19 + // use ATProto SDK for proper handle resolution (works with any PDS) 20 + const agent = new AtpAgent({ service: 'https://public.api.bsky.app' }); 21 + const response = await agent.resolveHandle({ handle }); 22 22 23 - if (response.ok) { 24 - const data = await response.json(); 25 - if (data.did) { 26 - blueskyProfileExists = true; 27 - blueskyUrl = `https://bsky.app/profile/${handle}`; 28 - } 23 + if (response.data.did) { 24 + blueskyProfileExists = true; 25 + blueskyUrl = `https://bsky.app/profile/${handle}`; 29 26 } 30 - } catch (e) { 31 - console.error('failed to check Bluesky:', e); 27 + } catch (_e) { 28 + // handle doesn't exist on ATProto network 29 + blueskyProfileExists = false; 32 30 } finally { 33 31 checkingBluesky = false; 34 32 }
+13 -4
frontend/src/routes/u/[handle]/+page.svelte
··· 31 31 const albums = $derived(data.albums ?? []); 32 32 let shareUrl = $state(''); 33 33 34 + // compute support URL - handle 'atprotofans' magic value 35 + const supportUrl = $derived(() => { 36 + if (!artist?.support_url) return null; 37 + if (artist.support_url === 'atprotofans') { 38 + return `https://atprotofans.com/u/${artist.did}`; 39 + } 40 + return artist.support_url; 41 + }); 42 + 34 43 $effect(() => { 35 44 if (!artist?.handle) { 36 45 shareUrl = ''; ··· 272 281 {/if} 273 282 </div> 274 283 <div class="artist-actions-desktop"> 275 - {#if artist.support_url} 276 - <a href={artist.support_url} target="_blank" rel="noopener" class="support-btn"> 284 + {#if supportUrl()} 285 + <a href={supportUrl()} target="_blank" rel="noopener" class="support-btn"> 277 286 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> 278 287 <path 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"/> 279 288 </svg> ··· 284 293 </div> 285 294 </div> 286 295 <div class="artist-actions-mobile"> 287 - {#if artist.support_url} 288 - <a href={artist.support_url} target="_blank" rel="noopener" class="support-btn"> 296 + {#if supportUrl()} 297 + <a href={supportUrl()} target="_blank" rel="noopener" class="support-btn"> 289 298 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> 290 299 <path 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"/> 291 300 </svg>