add SvelteKit frontend and OAuth 2.1 authentication

## frontend
- replace basic HTML with SvelteKit + bun for proper UI framework
- create home page with track listings and audio player
- create login page with OAuth flow initiation
- create artist portal with track upload form
- use dark theme consistent with relay aesthetic

## authentication
- implement OAuth 2.1 using atproto fork (git+https://github.com/zzstoatzz/atproto@main)
- add OAuth endpoints: /auth/start, /auth/callback, /auth/session
- handle cross-domain cookie issues for ngrok → localhost redirect
- add /client-metadata.json endpoint for OAuth discovery
- store sessions in-memory (MVP - migrate to redis later)

## api changes
- remove old HTML frontend routes (frontend.py)
- update auth endpoints to use OAuth instead of app passwords
- add intermediate /auth/session endpoint for localhost cookie setting
- improve error messages in upload endpoint

## configuration
- add OAuth settings to config (client_id, redirect_uri)
- update .env.example with OAuth configuration
- simplify justfile to minimal commands

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

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

+22
.env.example
··· 1 + # relay configuration 2 + 3 + # app 4 + DEBUG=false 5 + 6 + # database 7 + DATABASE_URL=postgresql+asyncpg://localhost/relay 8 + 9 + # redis 10 + REDIS_URL=redis://localhost:6379/0 11 + 12 + # cloudflare r2 13 + R2_ACCOUNT_ID= 14 + R2_ACCESS_KEY_ID= 15 + R2_SECRET_ACCESS_KEY= 16 + R2_BUCKET_NAME=relay-audio 17 + 18 + # atproto 19 + ATPROTO_PDS_URL=https://bsky.social 20 + ATPROTO_CLIENT_ID= 21 + ATPROTO_CLIENT_SECRET= 22 + ATPROTO_REDIRECT_URI=http://localhost:8000/auth/callback
+3 -4
CLAUDE.md
··· 5 5 ## critical reminders 6 6 7 7 - **testing**: empirical first - run code and prove it works before writing tests 8 - - **atproto client**: always pass PDS URL at initialization to avoid JWT issues 9 - - **auth**: using app password authentication for MVP (OAuth support being added upstream) 8 + - **auth**: OAuth 2.1 implementation from fork (`git+https://github.com/zzstoatzz/atproto@main`) 10 9 - **storage**: filesystem for MVP, will migrate to R2 later 11 10 - **database**: delete `data/relay.db` when Track model changes (no migrations yet) 12 - - **frontend**: SvelteKit - reference project in `sandbox/huggingchat-ui` for patterns 13 - - **justfile**: use `just` for all dev workflows (see `just --list`) 11 + - **frontend**: SvelteKit with **bun** (not npm/pnpm) - reference project in `sandbox/huggingchat-ui` for patterns 12 + - **justfile**: use `just` for dev workflows when needed
+23
frontend/.gitignore
··· 1 + node_modules 2 + 3 + # Output 4 + .output 5 + .vercel 6 + .netlify 7 + .wrangler 8 + /.svelte-kit 9 + /build 10 + 11 + # OS 12 + .DS_Store 13 + Thumbs.db 14 + 15 + # Env 16 + .env 17 + .env.* 18 + !.env.example 19 + !.env.test 20 + 21 + # Vite 22 + vite.config.js.timestamp-* 23 + vite.config.ts.timestamp-*
+1
frontend/.npmrc
··· 1 + engine-strict=true
+38
frontend/README.md
··· 1 + # sv 2 + 3 + Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). 4 + 5 + ## Creating a project 6 + 7 + If you're seeing this, you've probably already done this step. Congrats! 8 + 9 + ```sh 10 + # create a new project in the current directory 11 + npx sv create 12 + 13 + # create a new project in my-app 14 + npx sv create my-app 15 + ``` 16 + 17 + ## Developing 18 + 19 + Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 20 + 21 + ```sh 22 + npm run dev 23 + 24 + # or start the server and open the app in a new browser tab 25 + npm run dev -- --open 26 + ``` 27 + 28 + ## Building 29 + 30 + To create a production version of your app: 31 + 32 + ```sh 33 + npm run build 34 + ``` 35 + 36 + You can preview the production build with `npm run preview`. 37 + 38 + > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
frontend/bun.lockb

This is a binary file and will not be displayed.

+23
frontend/package.json
··· 1 + { 2 + "name": "frontend", 3 + "private": true, 4 + "version": "0.0.1", 5 + "type": "module", 6 + "scripts": { 7 + "dev": "vite dev", 8 + "build": "vite build", 9 + "preview": "vite preview", 10 + "prepare": "svelte-kit sync || echo ''", 11 + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 13 + }, 14 + "devDependencies": { 15 + "@sveltejs/adapter-auto": "^6.1.0", 16 + "@sveltejs/kit": "^2.43.2", 17 + "@sveltejs/vite-plugin-svelte": "^6.2.0", 18 + "svelte": "^5.39.5", 19 + "svelte-check": "^4.3.2", 20 + "typescript": "^5.9.2", 21 + "vite": "^7.1.7" 22 + } 23 + }
+13
frontend/src/app.d.ts
··· 1 + // See https://svelte.dev/docs/kit/types#app.d.ts 2 + // for information about these interfaces 3 + declare global { 4 + namespace App { 5 + // interface Error {} 6 + // interface Locals {} 7 + // interface PageData {} 8 + // interface PageState {} 9 + // interface Platform {} 10 + } 11 + } 12 + 13 + export {};
+11
frontend/src/app.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + %sveltekit.head% 7 + </head> 8 + <body data-sveltekit-preload-data="hover"> 9 + <div style="display: contents">%sveltekit.body%</div> 10 + </body> 11 + </html>
+1
frontend/src/lib/assets/favicon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
+1
frontend/src/lib/index.ts
··· 1 + // place files you want to import through the `$lib` alias in this folder.
+11
frontend/src/routes/+layout.svelte
··· 1 + <script lang="ts"> 2 + import favicon from '$lib/assets/favicon.svg'; 3 + 4 + let { children } = $props(); 5 + </script> 6 + 7 + <svelte:head> 8 + <link rel="icon" href={favicon} /> 9 + </svelte:head> 10 + 11 + {@render children?.()}
+291
frontend/src/routes/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + 4 + interface Track { 5 + id: number; 6 + title: string; 7 + artist: string; 8 + album?: string; 9 + file_id: string; 10 + file_type: string; 11 + artist_handle: string; 12 + } 13 + 14 + interface User { 15 + did: string; 16 + handle: string; 17 + } 18 + 19 + let tracks: Track[] = []; 20 + let currentTrack: Track | null = null; 21 + let audioElement: HTMLAudioElement; 22 + let user: User | null = null; 23 + 24 + onMount(async () => { 25 + // check authentication 26 + try { 27 + const authResponse = await fetch('http://localhost:8000/auth/me', { 28 + credentials: 'include' 29 + }); 30 + if (authResponse.ok) { 31 + user = await authResponse.json(); 32 + } 33 + } catch (e) { 34 + // not authenticated, that's fine 35 + } 36 + 37 + // load tracks 38 + const response = await fetch('http://localhost:8000/tracks/'); 39 + const data = await response.json(); 40 + tracks = data.tracks; 41 + }); 42 + 43 + function playTrack(track: Track) { 44 + currentTrack = track; 45 + if (audioElement) { 46 + audioElement.src = `http://localhost:8000/audio/${track.file_id}`; 47 + audioElement.play(); 48 + } 49 + } 50 + 51 + async function logout() { 52 + await fetch('http://localhost:8000/auth/logout', { 53 + method: 'POST', 54 + credentials: 'include' 55 + }); 56 + user = null; 57 + } 58 + </script> 59 + 60 + <main> 61 + <header> 62 + <div class="header-top"> 63 + <div> 64 + <h1>relay</h1> 65 + <p>decentralized music on ATProto</p> 66 + </div> 67 + <div class="auth-section"> 68 + {#if user} 69 + <span class="user-info">@{user.handle}</span> 70 + <button onclick={logout} class="logout-btn">logout</button> 71 + {:else} 72 + <a href="/login" class="login-link">login</a> 73 + {/if} 74 + </div> 75 + </div> 76 + {#if user} 77 + <a href="/portal">artist portal →</a> 78 + {/if} 79 + </header> 80 + 81 + <section class="tracks"> 82 + <h2>latest tracks</h2> 83 + {#if tracks.length === 0} 84 + <p class="empty">no tracks yet</p> 85 + {:else} 86 + <div class="track-list"> 87 + {#each tracks as track} 88 + <button 89 + class="track" 90 + class:playing={currentTrack?.id === track.id} 91 + onclick={() => playTrack(track)} 92 + > 93 + <div class="track-info"> 94 + <div class="track-title">{track.title}</div> 95 + <div class="track-artist"> 96 + {track.artist} 97 + {#if track.album} 98 + <span class="album">- {track.album}</span> 99 + {/if} 100 + </div> 101 + <div class="track-meta">@{track.artist_handle}</div> 102 + </div> 103 + </button> 104 + {/each} 105 + </div> 106 + {/if} 107 + </section> 108 + 109 + {#if currentTrack} 110 + <div class="player"> 111 + <div class="now-playing"> 112 + <strong>{currentTrack.title}</strong> by {currentTrack.artist} 113 + </div> 114 + <audio bind:this={audioElement} controls></audio> 115 + </div> 116 + {/if} 117 + </main> 118 + 119 + <style> 120 + :global(body) { 121 + margin: 0; 122 + padding: 0; 123 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 124 + background: #0a0a0a; 125 + color: #fff; 126 + } 127 + 128 + main { 129 + max-width: 800px; 130 + margin: 0 auto; 131 + padding: 2rem 1rem 120px; 132 + } 133 + 134 + header { 135 + margin-bottom: 3rem; 136 + } 137 + 138 + .header-top { 139 + display: flex; 140 + justify-content: space-between; 141 + align-items: flex-start; 142 + margin-bottom: 1rem; 143 + } 144 + 145 + h1 { 146 + font-size: 2.5rem; 147 + margin: 0 0 0.5rem; 148 + } 149 + 150 + header p { 151 + color: #888; 152 + margin: 0 0 1rem; 153 + } 154 + 155 + .auth-section { 156 + display: flex; 157 + align-items: center; 158 + gap: 1rem; 159 + } 160 + 161 + .user-info { 162 + color: #aaa; 163 + font-size: 0.9rem; 164 + } 165 + 166 + .logout-btn { 167 + background: transparent; 168 + border: 1px solid #444; 169 + color: #aaa; 170 + padding: 0.4rem 0.8rem; 171 + border-radius: 4px; 172 + cursor: pointer; 173 + font-size: 0.9rem; 174 + transition: all 0.2s; 175 + } 176 + 177 + .logout-btn:hover { 178 + border-color: #666; 179 + color: #fff; 180 + } 181 + 182 + .login-link { 183 + color: #3a7dff; 184 + text-decoration: none; 185 + font-size: 0.9rem; 186 + padding: 0.4rem 0.8rem; 187 + border: 1px solid #3a7dff; 188 + border-radius: 4px; 189 + transition: all 0.2s; 190 + } 191 + 192 + .login-link:hover { 193 + background: #3a7dff; 194 + color: white; 195 + } 196 + 197 + header > a { 198 + color: #3a7dff; 199 + text-decoration: none; 200 + font-size: 0.9rem; 201 + } 202 + 203 + header > a:hover { 204 + text-decoration: underline; 205 + } 206 + 207 + .tracks h2 { 208 + font-size: 1.5rem; 209 + margin-bottom: 1.5rem; 210 + } 211 + 212 + .empty { 213 + color: #666; 214 + padding: 2rem; 215 + text-align: center; 216 + } 217 + 218 + .track-list { 219 + display: flex; 220 + flex-direction: column; 221 + gap: 0.5rem; 222 + } 223 + 224 + .track { 225 + background: #1a1a1a; 226 + border: 1px solid #2a2a2a; 227 + border-left: 3px solid transparent; 228 + padding: 1rem; 229 + cursor: pointer; 230 + text-align: left; 231 + transition: all 0.2s; 232 + width: 100%; 233 + } 234 + 235 + .track:hover { 236 + background: #222; 237 + border-left-color: #3a7dff; 238 + } 239 + 240 + .track.playing { 241 + background: #1a2332; 242 + border-left-color: #3a7dff; 243 + } 244 + 245 + .track-title { 246 + font-weight: 600; 247 + font-size: 1.1rem; 248 + margin-bottom: 0.25rem; 249 + } 250 + 251 + .track-artist { 252 + color: #aaa; 253 + margin-bottom: 0.25rem; 254 + } 255 + 256 + .album { 257 + color: #888; 258 + } 259 + 260 + .track-meta { 261 + font-size: 0.85rem; 262 + color: #666; 263 + } 264 + 265 + .player { 266 + position: fixed; 267 + bottom: 0; 268 + left: 0; 269 + right: 0; 270 + background: #1a1a1a; 271 + border-top: 1px solid #2a2a2a; 272 + padding: 1rem; 273 + display: flex; 274 + align-items: center; 275 + gap: 1rem; 276 + } 277 + 278 + .now-playing { 279 + flex: 1; 280 + min-width: 0; 281 + } 282 + 283 + .now-playing strong { 284 + color: #fff; 285 + } 286 + 287 + audio { 288 + flex: 1; 289 + max-width: 400px; 290 + } 291 + </style>
+135
frontend/src/routes/login/+page.svelte
··· 1 + <script lang="ts"> 2 + let handle = ''; 3 + let loading = false; 4 + 5 + function startOAuth() { 6 + if (!handle.trim()) return; 7 + loading = true; 8 + // redirect to backend OAuth start endpoint 9 + window.location.href = `http://localhost:8000/auth/start?handle=${encodeURIComponent(handle)}`; 10 + } 11 + </script> 12 + 13 + <div class="container"> 14 + <div class="login-card"> 15 + <h1>relay</h1> 16 + <p>decentralized music streaming</p> 17 + 18 + <form on:submit|preventDefault={startOAuth}> 19 + <div class="input-group"> 20 + <label for="handle">bluesky handle</label> 21 + <input 22 + id="handle" 23 + type="text" 24 + bind:value={handle} 25 + placeholder="yourname.bsky.social" 26 + disabled={loading} 27 + required 28 + /> 29 + </div> 30 + 31 + <button type="submit" disabled={loading || !handle.trim()}> 32 + {loading ? 'redirecting...' : 'login with bluesky'} 33 + </button> 34 + </form> 35 + </div> 36 + </div> 37 + 38 + <style> 39 + .container { 40 + min-height: 100vh; 41 + display: flex; 42 + align-items: center; 43 + justify-content: center; 44 + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); 45 + padding: 1rem; 46 + } 47 + 48 + .login-card { 49 + background: #0f3460; 50 + border-radius: 12px; 51 + padding: 3rem; 52 + max-width: 400px; 53 + width: 100%; 54 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); 55 + } 56 + 57 + h1 { 58 + font-size: 2.5rem; 59 + margin: 0 0 0.5rem 0; 60 + color: #e94560; 61 + text-align: center; 62 + } 63 + 64 + p { 65 + color: rgba(255, 255, 255, 0.7); 66 + text-align: center; 67 + margin: 0 0 2rem 0; 68 + } 69 + 70 + .input-group { 71 + margin-bottom: 1.5rem; 72 + } 73 + 74 + label { 75 + display: block; 76 + color: rgba(255, 255, 255, 0.9); 77 + margin-bottom: 0.5rem; 78 + font-size: 0.9rem; 79 + } 80 + 81 + input { 82 + width: 100%; 83 + padding: 0.75rem; 84 + background: rgba(255, 255, 255, 0.05); 85 + border: 1px solid rgba(255, 255, 255, 0.1); 86 + border-radius: 6px; 87 + color: white; 88 + font-size: 1rem; 89 + transition: all 0.2s; 90 + } 91 + 92 + input:focus { 93 + outline: none; 94 + border-color: #e94560; 95 + background: rgba(255, 255, 255, 0.08); 96 + } 97 + 98 + input:disabled { 99 + opacity: 0.5; 100 + cursor: not-allowed; 101 + } 102 + 103 + input::placeholder { 104 + color: rgba(255, 255, 255, 0.3); 105 + } 106 + 107 + button { 108 + width: 100%; 109 + padding: 0.75rem; 110 + background: #e94560; 111 + color: white; 112 + border: none; 113 + border-radius: 6px; 114 + font-size: 1rem; 115 + font-weight: 600; 116 + cursor: pointer; 117 + transition: all 0.2s; 118 + } 119 + 120 + button:hover:not(:disabled) { 121 + background: #d63651; 122 + transform: translateY(-1px); 123 + box-shadow: 0 4px 12px rgba(233, 69, 96, 0.3); 124 + } 125 + 126 + button:disabled { 127 + opacity: 0.5; 128 + cursor: not-allowed; 129 + transform: none; 130 + } 131 + 132 + button:active:not(:disabled) { 133 + transform: translateY(0); 134 + } 135 + </style>
+344
frontend/src/routes/portal/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + 4 + interface User { 5 + did: string; 6 + handle: string; 7 + } 8 + 9 + let user: User | null = null; 10 + let loading = true; 11 + 12 + // form state 13 + let uploading = false; 14 + let uploadError = ''; 15 + let uploadSuccess = ''; 16 + 17 + // form fields 18 + let title = ''; 19 + let artist = ''; 20 + let album = ''; 21 + let file: File | null = null; 22 + 23 + onMount(async () => { 24 + try { 25 + const response = await fetch('http://localhost:8000/auth/me', { 26 + credentials: 'include' 27 + }); 28 + if (response.ok) { 29 + user = await response.json(); 30 + } else { 31 + // not authenticated, redirect to login 32 + window.location.href = '/login'; 33 + } 34 + } catch (e) { 35 + window.location.href = '/login'; 36 + } finally { 37 + loading = false; 38 + } 39 + }); 40 + 41 + async function handleUpload(e: Event) { 42 + e.preventDefault(); 43 + if (!file) return; 44 + 45 + uploading = true; 46 + uploadError = ''; 47 + uploadSuccess = ''; 48 + 49 + const formData = new FormData(); 50 + formData.append('file', file); 51 + formData.append('title', title); 52 + formData.append('artist', artist); 53 + if (album) formData.append('album', album); 54 + 55 + try { 56 + const response = await fetch('http://localhost:8000/tracks/', { 57 + method: 'POST', 58 + body: formData, 59 + credentials: 'include' 60 + }); 61 + 62 + if (response.ok) { 63 + uploadSuccess = 'track uploaded successfully!'; 64 + // reset form 65 + title = ''; 66 + artist = ''; 67 + album = ''; 68 + file = null; 69 + // @ts-ignore 70 + document.getElementById('file-input').value = ''; 71 + } else { 72 + const error = await response.json(); 73 + uploadError = error.detail || `upload failed (${response.status} ${response.statusText})`; 74 + } 75 + } catch (e) { 76 + uploadError = `network error: ${e instanceof Error ? e.message : 'unknown error'}`; 77 + } finally { 78 + uploading = false; 79 + } 80 + } 81 + 82 + function handleFileChange(e: Event) { 83 + const target = e.target as HTMLInputElement; 84 + if (target.files && target.files[0]) { 85 + file = target.files[0]; 86 + } 87 + } 88 + </script> 89 + 90 + {#if loading} 91 + <div class="loading">loading...</div> 92 + {:else if user} 93 + <main> 94 + <header> 95 + <div class="header-content"> 96 + <div> 97 + <h1>artist portal</h1> 98 + <p class="user-info">logged in as @{user.handle}</p> 99 + </div> 100 + <a href="/" class="back-link">← back to tracks</a> 101 + </div> 102 + </header> 103 + 104 + <section class="upload-section"> 105 + <h2>upload track</h2> 106 + 107 + {#if uploadSuccess} 108 + <div class="message success">{uploadSuccess}</div> 109 + {/if} 110 + 111 + {#if uploadError} 112 + <div class="message error">{uploadError}</div> 113 + {/if} 114 + 115 + <form on:submit={handleUpload}> 116 + <div class="form-group"> 117 + <label for="title">track title</label> 118 + <input 119 + id="title" 120 + type="text" 121 + bind:value={title} 122 + required 123 + disabled={uploading} 124 + placeholder="my awesome song" 125 + /> 126 + </div> 127 + 128 + <div class="form-group"> 129 + <label for="artist">artist name</label> 130 + <input 131 + id="artist" 132 + type="text" 133 + bind:value={artist} 134 + required 135 + disabled={uploading} 136 + placeholder="artist name" 137 + /> 138 + </div> 139 + 140 + <div class="form-group"> 141 + <label for="album">album (optional)</label> 142 + <input 143 + id="album" 144 + type="text" 145 + bind:value={album} 146 + disabled={uploading} 147 + placeholder="album name" 148 + /> 149 + </div> 150 + 151 + <div class="form-group"> 152 + <label for="file-input">audio file</label> 153 + <input 154 + id="file-input" 155 + type="file" 156 + accept="audio/*" 157 + on:change={handleFileChange} 158 + required 159 + disabled={uploading} 160 + /> 161 + {#if file} 162 + <p class="file-info">{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)</p> 163 + {/if} 164 + </div> 165 + 166 + <button type="submit" disabled={uploading || !file}> 167 + {uploading ? 'uploading...' : 'upload track'} 168 + </button> 169 + </form> 170 + </section> 171 + </main> 172 + {/if} 173 + 174 + <style> 175 + :global(body) { 176 + margin: 0; 177 + padding: 0; 178 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 179 + background: #0a0a0a; 180 + color: #fff; 181 + } 182 + 183 + .loading { 184 + display: flex; 185 + align-items: center; 186 + justify-content: center; 187 + min-height: 100vh; 188 + color: #888; 189 + } 190 + 191 + main { 192 + max-width: 800px; 193 + margin: 0 auto; 194 + padding: 2rem 1rem; 195 + } 196 + 197 + header { 198 + margin-bottom: 3rem; 199 + } 200 + 201 + .header-content { 202 + display: flex; 203 + justify-content: space-between; 204 + align-items: flex-start; 205 + } 206 + 207 + h1 { 208 + font-size: 2.5rem; 209 + margin: 0 0 0.5rem; 210 + } 211 + 212 + .user-info { 213 + color: #888; 214 + margin: 0; 215 + } 216 + 217 + .back-link { 218 + color: #3a7dff; 219 + text-decoration: none; 220 + font-size: 0.9rem; 221 + padding: 0.5rem 1rem; 222 + border: 1px solid #3a7dff; 223 + border-radius: 4px; 224 + transition: all 0.2s; 225 + } 226 + 227 + .back-link:hover { 228 + background: #3a7dff; 229 + color: white; 230 + } 231 + 232 + .upload-section h2 { 233 + font-size: 1.5rem; 234 + margin-bottom: 1.5rem; 235 + } 236 + 237 + .message { 238 + padding: 1rem; 239 + border-radius: 4px; 240 + margin-bottom: 1.5rem; 241 + } 242 + 243 + .message.success { 244 + background: rgba(46, 160, 67, 0.1); 245 + border: 1px solid rgba(46, 160, 67, 0.3); 246 + color: #5ce87b; 247 + } 248 + 249 + .message.error { 250 + background: rgba(233, 69, 96, 0.1); 251 + border: 1px solid rgba(233, 69, 96, 0.3); 252 + color: #ff6b6b; 253 + } 254 + 255 + form { 256 + background: #1a1a1a; 257 + padding: 2rem; 258 + border-radius: 8px; 259 + border: 1px solid #2a2a2a; 260 + } 261 + 262 + .form-group { 263 + margin-bottom: 1.5rem; 264 + } 265 + 266 + label { 267 + display: block; 268 + color: #aaa; 269 + margin-bottom: 0.5rem; 270 + font-size: 0.9rem; 271 + } 272 + 273 + input[type='text'] { 274 + width: 100%; 275 + padding: 0.75rem; 276 + background: #0a0a0a; 277 + border: 1px solid #333; 278 + border-radius: 4px; 279 + color: white; 280 + font-size: 1rem; 281 + transition: all 0.2s; 282 + } 283 + 284 + input[type='text']:focus { 285 + outline: none; 286 + border-color: #3a7dff; 287 + } 288 + 289 + input[type='text']:disabled { 290 + opacity: 0.5; 291 + cursor: not-allowed; 292 + } 293 + 294 + input[type='file'] { 295 + width: 100%; 296 + padding: 0.75rem; 297 + background: #0a0a0a; 298 + border: 1px solid #333; 299 + border-radius: 4px; 300 + color: white; 301 + font-size: 0.9rem; 302 + cursor: pointer; 303 + } 304 + 305 + input[type='file']:disabled { 306 + opacity: 0.5; 307 + cursor: not-allowed; 308 + } 309 + 310 + .file-info { 311 + margin-top: 0.5rem; 312 + font-size: 0.85rem; 313 + color: #666; 314 + } 315 + 316 + button { 317 + width: 100%; 318 + padding: 0.75rem; 319 + background: #3a7dff; 320 + color: white; 321 + border: none; 322 + border-radius: 4px; 323 + font-size: 1rem; 324 + font-weight: 600; 325 + cursor: pointer; 326 + transition: all 0.2s; 327 + } 328 + 329 + button:hover:not(:disabled) { 330 + background: #2868e6; 331 + transform: translateY(-1px); 332 + box-shadow: 0 4px 12px rgba(58, 125, 255, 0.3); 333 + } 334 + 335 + button:disabled { 336 + opacity: 0.5; 337 + cursor: not-allowed; 338 + transform: none; 339 + } 340 + 341 + button:active:not(:disabled) { 342 + transform: translateY(0); 343 + } 344 + </style>
+3
frontend/static/robots.txt
··· 1 + # allow crawling everything by default 2 + User-agent: * 3 + Disallow:
+18
frontend/svelte.config.js
··· 1 + import adapter from '@sveltejs/adapter-auto'; 2 + import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 + 4 + /** @type {import('@sveltejs/kit').Config} */ 5 + const config = { 6 + // Consult https://svelte.dev/docs/kit/integrations 7 + // for more information about preprocessors 8 + preprocess: vitePreprocess(), 9 + 10 + kit: { 11 + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 12 + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 + // See https://svelte.dev/docs/kit/adapters for more information about adapters. 14 + adapter: adapter() 15 + } 16 + }; 17 + 18 + export default config;
+19
frontend/tsconfig.json
··· 1 + { 2 + "extends": "./.svelte-kit/tsconfig.json", 3 + "compilerOptions": { 4 + "allowJs": true, 5 + "checkJs": true, 6 + "esModuleInterop": true, 7 + "forceConsistentCasingInFileNames": true, 8 + "resolveJsonModule": true, 9 + "skipLibCheck": true, 10 + "sourceMap": true, 11 + "strict": true, 12 + "moduleResolution": "bundler" 13 + } 14 + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 15 + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files 16 + // 17 + // To make changes to top-level options such as include and exclude, we recommend extending 18 + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript 19 + }
+6
frontend/vite.config.ts
··· 1 + import { sveltekit } from '@sveltejs/kit/vite'; 2 + import { defineConfig } from 'vite'; 3 + 4 + export default defineConfig({ 5 + plugins: [sveltekit()] 6 + });
+4 -100
justfile
··· 1 - # relay justfile - common dev workflows 1 + # relay dev workflows 2 2 3 - # default recipe shows all available commands 3 + # show available commands 4 4 default: 5 5 @just --list 6 6 7 - # ==================== 8 - # backend (python/fastapi) 9 - # ==================== 10 - 11 - # install python dependencies 12 - install: 13 - uv sync 14 - 15 - # run backend dev server 7 + # run backend server 16 8 serve: 17 9 uv run uvicorn relay.main:app --reload --host 0.0.0.0 --port 8000 18 10 19 - # run pre-commit hooks 20 - lint: 21 - uv run pre-commit run --all-files 22 - 23 - # run python tests 24 - test: 25 - uv run pytest 26 - 27 - # type check python code 28 - typecheck: 29 - uv run pyright 30 - 31 - # ==================== 32 - # frontend (sveltekit) 33 - # ==================== 34 - 35 - # install frontend dependencies 36 - [group('frontend')] 37 - fe-install: 38 - cd frontend && npm install 39 - 40 11 # run frontend dev server 41 - [group('frontend')] 42 - fe-dev: 43 - cd frontend && npm run dev 44 - 45 - # build frontend for production 46 - [group('frontend')] 47 - fe-build: 48 - cd frontend && npm run build 49 - 50 - # preview production frontend build 51 - [group('frontend')] 52 - fe-preview: 53 - cd frontend && npm run preview 54 - 55 - # check frontend types and svelte errors 56 - [group('frontend')] 57 - fe-check: 58 - cd frontend && npm run check 59 - 60 - # lint frontend code 61 - [group('frontend')] 62 - fe-lint: 63 - cd frontend && npm run lint 64 - 65 - # format frontend code 66 - [group('frontend')] 67 - fe-format: 68 - cd frontend && npm run format 69 - 70 - # run frontend tests 71 - [group('frontend')] 72 - fe-test: 73 - cd frontend && npm run test 74 - 75 - # ==================== 76 - # database 77 - # ==================== 78 - 79 - # delete database (requires fresh start) 80 - db-reset: 81 - rm -f data/relay.db 82 - @echo "database deleted - will be recreated on next server start" 83 - 84 - # ==================== 85 - # development 86 - # ==================== 87 - 88 - # run both backend and frontend in parallel 89 12 dev: 90 - #!/usr/bin/env bash 91 - trap 'kill 0' EXIT 92 - just serve & 93 - just fe-dev & 94 - wait 95 - 96 - # format all code (python + frontend) 97 - format: fe-format 98 - uv run ruff format . 99 - 100 - # check everything (lint + typecheck + test) 101 - check: lint typecheck fe-check fe-lint test fe-test 102 - @echo "✓ all checks passed" 103 - 104 - # clean all generated files 105 - clean: 106 - rm -rf frontend/node_modules frontend/.svelte-kit frontend/build 107 - rm -rf .venv 108 - rm -f data/relay.db 109 - @echo "cleaned all generated files" 13 + cd frontend && bun run dev
+1 -1
pyproject.toml
··· 14 14 "alembic>=1.14.0", 15 15 "asyncpg>=0.30.0", 16 16 "redis[hiredis]>=5.2.0", 17 - "atproto>=0.0.55", 17 + "atproto @ git+https://github.com/zzstoatzz/atproto@main", 18 18 "boto3>=1.37.0", 19 19 "python-multipart>=0.0.20", 20 20 "python-jose[cryptography]>=3.3.0",
+1 -2
src/relay/api/__init__.py
··· 2 2 3 3 from relay.api.audio import router as audio_router 4 4 from relay.api.auth import router as auth_router 5 - from relay.api.frontend import router as frontend_router 6 5 from relay.api.tracks import router as tracks_router 7 6 8 - __all__ = ["audio_router", "auth_router", "frontend_router", "tracks_router"] 7 + __all__ = ["audio_router", "auth_router", "tracks_router"]
+39 -18
src/relay/api/auth.py
··· 2 2 3 3 from typing import Annotated 4 4 5 - from fastapi import APIRouter, Depends, Form 6 - from fastapi.responses import JSONResponse 5 + from fastapi import APIRouter, Depends, Query 6 + from fastapi.responses import JSONResponse, RedirectResponse 7 7 8 - from relay.auth import Session, create_session, delete_session, require_auth, verify_app_password 8 + from relay.auth import ( 9 + Session, 10 + create_session, 11 + delete_session, 12 + handle_oauth_callback, 13 + require_auth, 14 + start_oauth_flow, 15 + ) 9 16 10 17 router = APIRouter(prefix="/auth", tags=["auth"]) 11 18 12 19 13 - @router.post("/login") 14 - async def login( 15 - handle: Annotated[str, Form()], 16 - app_password: Annotated[str, Form()], 17 - ) -> JSONResponse: 18 - """login with atproto app password.""" 19 - did, verified_handle = verify_app_password(handle, app_password) 20 - session_id = create_session(did, verified_handle) 20 + @router.get("/start") 21 + async def start_login(handle: str) -> RedirectResponse: 22 + """start OAuth flow for a given handle.""" 23 + auth_url, _state = await start_oauth_flow(handle) 24 + return RedirectResponse(url=auth_url) 25 + 26 + 27 + @router.get("/callback") 28 + async def oauth_callback( 29 + code: Annotated[str, Query()], 30 + state: Annotated[str, Query()], 31 + iss: Annotated[str, Query()], 32 + ) -> RedirectResponse: 33 + """handle OAuth callback and create session.""" 34 + did, handle, oauth_session = await handle_oauth_callback(code, state, iss) 35 + session_id = create_session(did, handle, oauth_session) 21 36 22 - response = JSONResponse( 23 - content={ 24 - "did": did, 25 - "handle": verified_handle, 26 - "message": "logged in successfully", 27 - } 37 + # redirect to localhost endpoint to set cookie properly for localhost domain 38 + response = RedirectResponse( 39 + url=f"http://localhost:8000/auth/session?session_id={session_id}", 40 + status_code=303 28 41 ) 42 + return response 43 + 44 + 45 + @router.get("/session") 46 + async def set_session_cookie(session_id: str) -> RedirectResponse: 47 + """intermediate endpoint to set session cookie for localhost domain.""" 48 + response = RedirectResponse(url="http://localhost:5173", status_code=303) 29 49 response.set_cookie( 30 50 key="session_id", 31 51 value=session_id, 32 52 httponly=True, 33 - secure=False, # set to True in production with HTTPS 53 + secure=False, 34 54 samesite="lax", 55 + domain="localhost", 35 56 ) 36 57 return response 37 58
-572
src/relay/api/frontend.py
··· 1 - """frontend html pages.""" 2 - 3 - from fastapi import APIRouter 4 - from fastapi.responses import HTMLResponse 5 - 6 - router = APIRouter(tags=["frontend"]) 7 - 8 - 9 - @router.get("/portal", response_class=HTMLResponse) 10 - async def artist_portal() -> str: 11 - """artist upload portal with authentication.""" 12 - return """ 13 - <!DOCTYPE html> 14 - <html> 15 - <head> 16 - <title>relay - artist portal</title> 17 - <meta charset="utf-8"> 18 - <meta name="viewport" content="width=device-width, initial-scale=1"> 19 - <style> 20 - * { margin: 0; padding: 0; box-sizing: border-box; } 21 - body { 22 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 23 - max-width: 800px; 24 - margin: 0 auto; 25 - padding: 20px; 26 - background: #0a0a0a; 27 - color: #e0e0e0; 28 - } 29 - h1 { margin-bottom: 10px; color: #fff; } 30 - 31 - nav { 32 - margin-bottom: 30px; 33 - padding-bottom: 15px; 34 - border-bottom: 1px solid #333; 35 - } 36 - 37 - nav a { 38 - color: #3a7dff; 39 - text-decoration: none; 40 - font-size: 14px; 41 - } 42 - 43 - nav a:hover { text-decoration: underline; } 44 - 45 - .upload-section { 46 - background: #1a1a1a; 47 - padding: 20px; 48 - border-radius: 8px; 49 - margin-bottom: 30px; 50 - } 51 - 52 - .upload-section h2 { margin-bottom: 15px; font-size: 18px; } 53 - 54 - input, button { 55 - display: block; 56 - width: 100%; 57 - padding: 10px; 58 - margin-bottom: 10px; 59 - border: 1px solid #333; 60 - border-radius: 4px; 61 - background: #2a2a2a; 62 - color: #e0e0e0; 63 - font-size: 14px; 64 - } 65 - 66 - button { 67 - background: #3a7dff; 68 - color: white; 69 - border: none; 70 - cursor: pointer; 71 - font-weight: 500; 72 - } 73 - 74 - button:hover { background: #2d6ee6; } 75 - button:disabled { background: #555; cursor: not-allowed; } 76 - 77 - .tracks-section h2 { margin-bottom: 15px; font-size: 18px; } 78 - 79 - .track { 80 - background: #1a1a1a; 81 - padding: 15px; 82 - margin-bottom: 10px; 83 - border-radius: 8px; 84 - cursor: pointer; 85 - transition: background 0.2s; 86 - } 87 - 88 - .track:hover { background: #252525; } 89 - .track.playing { background: #2a3f5f; } 90 - 91 - .track-title { font-weight: 500; margin-bottom: 5px; } 92 - .track-artist { color: #888; font-size: 14px; } 93 - 94 - .player { 95 - position: fixed; 96 - bottom: 0; 97 - left: 0; 98 - right: 0; 99 - background: #1a1a1a; 100 - border-top: 1px solid #333; 101 - padding: 15px; 102 - display: none; 103 - } 104 - 105 - .player.active { display: block; } 106 - 107 - .player-info { 108 - margin-bottom: 10px; 109 - text-align: center; 110 - } 111 - 112 - .player-title { font-weight: 500; } 113 - .player-artist { color: #888; font-size: 14px; } 114 - 115 - audio { 116 - width: 100%; 117 - margin-top: 10px; 118 - } 119 - 120 - .status { 121 - padding: 10px; 122 - margin-bottom: 10px; 123 - border-radius: 4px; 124 - display: none; 125 - } 126 - .status.success { background: #1a4d1a; display: block; } 127 - .status.error { background: #4d1a1a; display: block; } 128 - </style> 129 - </head> 130 - <body> 131 - <h1>relay - artist portal</h1> 132 - <nav> 133 - <a href="/">← back to tracks</a> 134 - <span id="userInfo" style="display:none; margin-left: 10px;"></span> 135 - <button id="logoutBtn" style="display:none; margin-left: 10px;">logout</button> 136 - </nav> 137 - 138 - <div id="loginSection" class="upload-section"> 139 - <h2>authenticate with bluesky</h2> 140 - <p style="color: #888; margin-bottom: 15px;">login with your bluesky handle and app password to upload tracks.</p> 141 - <div id="loginStatus" class="status"></div> 142 - <form id="loginForm"> 143 - <input type="text" id="handle" placeholder="your.handle.bsky.social" required> 144 - <input type="password" id="appPassword" placeholder="app password" required> 145 - <button type="submit">login</button> 146 - </form> 147 - <p style="color: #666; margin-top: 15px; font-size: 12px;"> 148 - get an app password from your <a href="https://bsky.app/settings/app-passwords" target="_blank" style="color: #3a7dff;">bluesky settings</a> 149 - </p> 150 - </div> 151 - 152 - <div id="uploadSection" class="upload-section" style="display:none;"> 153 - <h2>upload track</h2> 154 - <div id="uploadStatus" class="status"></div> 155 - <form id="uploadForm"> 156 - <input type="text" id="title" placeholder="title" required> 157 - <input type="text" id="artist" placeholder="artist" required> 158 - <input type="text" id="album" placeholder="album (optional)"> 159 - <input type="file" id="file" accept=".mp3,.wav,.m4a" required> 160 - <button type="submit">upload</button> 161 - </form> 162 - </div> 163 - 164 - <div class="tracks-section"> 165 - <h2>tracks</h2> 166 - <div id="tracks"></div> 167 - </div> 168 - 169 - <div class="player" id="player"> 170 - <div class="player-info"> 171 - <div class="player-title" id="playerTitle"></div> 172 - <div class="player-artist" id="playerArtist"></div> 173 - </div> 174 - <audio id="audio" controls></audio> 175 - </div> 176 - 177 - <script> 178 - let currentTrackId = null; 179 - let isAuthenticated = false; 180 - 181 - // check if user is authenticated 182 - async function checkAuth() { 183 - try { 184 - const response = await fetch('/auth/me'); 185 - if (response.ok) { 186 - const user = await response.json(); 187 - isAuthenticated = true; 188 - document.getElementById('loginSection').style.display = 'none'; 189 - document.getElementById('uploadSection').style.display = 'block'; 190 - document.getElementById('userInfo').style.display = 'inline'; 191 - document.getElementById('userInfo').textContent = `@${user.handle}`; 192 - document.getElementById('logoutBtn').style.display = 'inline'; 193 - } else { 194 - isAuthenticated = false; 195 - document.getElementById('loginSection').style.display = 'block'; 196 - document.getElementById('uploadSection').style.display = 'none'; 197 - document.getElementById('userInfo').style.display = 'none'; 198 - document.getElementById('logoutBtn').style.display = 'none'; 199 - } 200 - } catch (err) { 201 - console.error('auth check failed:', err); 202 - } 203 - } 204 - 205 - // handle login 206 - document.getElementById('loginForm').addEventListener('submit', async (e) => { 207 - e.preventDefault(); 208 - 209 - const form = e.target; 210 - const button = form.querySelector('button'); 211 - const status = document.getElementById('loginStatus'); 212 - 213 - button.disabled = true; 214 - button.textContent = 'logging in...'; 215 - status.className = 'status'; 216 - 217 - const formData = new FormData(); 218 - formData.append('handle', document.getElementById('handle').value); 219 - formData.append('app_password', document.getElementById('appPassword').value); 220 - 221 - try { 222 - const response = await fetch('/auth/login', { 223 - method: 'POST', 224 - body: formData 225 - }); 226 - 227 - if (response.ok) { 228 - status.className = 'status success'; 229 - status.textContent = 'logged in successfully!'; 230 - form.reset(); 231 - await checkAuth(); 232 - } else { 233 - const error = await response.json(); 234 - status.className = 'status error'; 235 - status.textContent = `error: ${error.detail}`; 236 - } 237 - } catch (err) { 238 - status.className = 'status error'; 239 - status.textContent = `error: ${err.message}`; 240 - } finally { 241 - button.disabled = false; 242 - button.textContent = 'login'; 243 - } 244 - }); 245 - 246 - // handle logout 247 - document.getElementById('logoutBtn').addEventListener('click', async () => { 248 - try { 249 - await fetch('/auth/logout', { method: 'POST' }); 250 - isAuthenticated = false; 251 - await checkAuth(); 252 - } catch (err) { 253 - console.error('logout failed:', err); 254 - } 255 - }); 256 - 257 - async function loadTracks() { 258 - const response = await fetch('/tracks'); 259 - const data = await response.json(); 260 - 261 - const tracksDiv = document.getElementById('tracks'); 262 - tracksDiv.innerHTML = ''; 263 - 264 - if (data.tracks.length === 0) { 265 - tracksDiv.innerHTML = '<p style="color: #666;">no tracks yet. upload one to get started.</p>'; 266 - return; 267 - } 268 - 269 - data.tracks.forEach(track => { 270 - const trackDiv = document.createElement('div'); 271 - trackDiv.className = 'track'; 272 - if (currentTrackId === track.id) { 273 - trackDiv.classList.add('playing'); 274 - } 275 - trackDiv.innerHTML = ` 276 - <div class="track-title">${track.title}</div> 277 - <div class="track-artist">${track.artist}${track.album ? ' - ' + track.album : ''}</div> 278 - `; 279 - trackDiv.onclick = () => playTrack(track); 280 - tracksDiv.appendChild(trackDiv); 281 - }); 282 - } 283 - 284 - function playTrack(track) { 285 - currentTrackId = track.id; 286 - 287 - const player = document.getElementById('player'); 288 - const audio = document.getElementById('audio'); 289 - const title = document.getElementById('playerTitle'); 290 - const artist = document.getElementById('playerArtist'); 291 - 292 - title.textContent = track.title; 293 - artist.textContent = track.artist + (track.album ? ' - ' + track.album : ''); 294 - audio.src = `/audio/${track.file_id}`; 295 - 296 - player.classList.add('active'); 297 - audio.play(); 298 - 299 - loadTracks(); // refresh to show playing state 300 - } 301 - 302 - document.getElementById('uploadForm').addEventListener('submit', async (e) => { 303 - e.preventDefault(); 304 - 305 - const form = e.target; 306 - const button = form.querySelector('button'); 307 - const status = document.getElementById('uploadStatus'); 308 - 309 - button.disabled = true; 310 - button.textContent = 'uploading...'; 311 - status.className = 'status'; 312 - 313 - const formData = new FormData(); 314 - formData.append('title', document.getElementById('title').value); 315 - formData.append('artist', document.getElementById('artist').value); 316 - const album = document.getElementById('album').value; 317 - if (album) formData.append('album', album); 318 - formData.append('file', document.getElementById('file').files[0]); 319 - 320 - try { 321 - const response = await fetch('/tracks/', { 322 - method: 'POST', 323 - body: formData 324 - }); 325 - 326 - if (response.ok) { 327 - status.className = 'status success'; 328 - status.textContent = 'track uploaded successfully!'; 329 - form.reset(); 330 - await loadTracks(); 331 - } else { 332 - const error = await response.json(); 333 - status.className = 'status error'; 334 - status.textContent = `error: ${error.detail}`; 335 - } 336 - } catch (err) { 337 - status.className = 'status error'; 338 - status.textContent = `error: ${err.message}`; 339 - } finally { 340 - button.disabled = false; 341 - button.textContent = 'upload'; 342 - } 343 - }); 344 - 345 - // initialize on page load 346 - checkAuth(); 347 - loadTracks(); 348 - </script> 349 - </body> 350 - </html> 351 - """ 352 - 353 - 354 - @router.get("/", response_class=HTMLResponse) 355 - async def index() -> str: 356 - """landing page with track discovery.""" 357 - return """ 358 - <!DOCTYPE html> 359 - <html> 360 - <head> 361 - <title>relay - decentralized music</title> 362 - <meta charset="utf-8"> 363 - <meta name="viewport" content="width=device-width, initial-scale=1"> 364 - <style> 365 - * { margin: 0; padding: 0; box-sizing: border-box; } 366 - body { 367 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 368 - max-width: 900px; 369 - margin: 0 auto; 370 - padding: 20px; 371 - background: #0a0a0a; 372 - color: #e0e0e0; 373 - } 374 - 375 - header { 376 - display: flex; 377 - justify-content: space-between; 378 - align-items: center; 379 - margin-bottom: 40px; 380 - padding-bottom: 20px; 381 - border-bottom: 1px solid #333; 382 - } 383 - 384 - h1 { 385 - color: #fff; 386 - font-size: 32px; 387 - } 388 - 389 - .portal-link { 390 - background: #3a7dff; 391 - color: white; 392 - padding: 10px 20px; 393 - border-radius: 6px; 394 - text-decoration: none; 395 - font-size: 14px; 396 - font-weight: 500; 397 - transition: background 0.2s; 398 - } 399 - 400 - .portal-link:hover { 401 - background: #2d6ee6; 402 - } 403 - 404 - .empty-state { 405 - text-align: center; 406 - padding: 60px 20px; 407 - color: #666; 408 - } 409 - 410 - .empty-state h2 { 411 - font-size: 20px; 412 - margin-bottom: 10px; 413 - color: #888; 414 - } 415 - 416 - .tracks-section h2 { 417 - margin-bottom: 20px; 418 - font-size: 18px; 419 - color: #aaa; 420 - } 421 - 422 - .track { 423 - background: #1a1a1a; 424 - padding: 20px; 425 - margin-bottom: 12px; 426 - border-radius: 8px; 427 - cursor: pointer; 428 - transition: background 0.2s, transform 0.1s; 429 - border-left: 3px solid transparent; 430 - } 431 - 432 - .track:hover { 433 - background: #252525; 434 - transform: translateX(2px); 435 - border-left-color: #3a7dff; 436 - } 437 - 438 - .track.playing { 439 - background: #2a3f5f; 440 - border-left-color: #3a7dff; 441 - } 442 - 443 - .track-title { 444 - font-weight: 500; 445 - font-size: 16px; 446 - margin-bottom: 6px; 447 - color: #fff; 448 - } 449 - 450 - .track-artist { 451 - color: #888; 452 - font-size: 14px; 453 - } 454 - 455 - .player { 456 - position: fixed; 457 - bottom: 0; 458 - left: 0; 459 - right: 0; 460 - background: #1a1a1a; 461 - border-top: 1px solid #333; 462 - padding: 20px; 463 - display: none; 464 - box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.5); 465 - } 466 - 467 - .player.active { display: block; } 468 - 469 - .player-info { 470 - max-width: 900px; 471 - margin: 0 auto 15px; 472 - text-align: center; 473 - } 474 - 475 - .player-title { 476 - font-weight: 500; 477 - font-size: 16px; 478 - color: #fff; 479 - } 480 - 481 - .player-artist { 482 - color: #888; 483 - font-size: 14px; 484 - margin-top: 4px; 485 - } 486 - 487 - audio { 488 - width: 100%; 489 - max-width: 900px; 490 - display: block; 491 - margin: 0 auto; 492 - } 493 - </style> 494 - </head> 495 - <body> 496 - <header> 497 - <h1>relay</h1> 498 - <a href="/portal" class="portal-link">artist portal →</a> 499 - </header> 500 - 501 - <div class="tracks-section"> 502 - <h2>latest tracks</h2> 503 - <div id="tracks"></div> 504 - </div> 505 - 506 - <div class="player" id="player"> 507 - <div class="player-info"> 508 - <div class="player-title" id="playerTitle"></div> 509 - <div class="player-artist" id="playerArtist"></div> 510 - </div> 511 - <audio id="audio" controls></audio> 512 - </div> 513 - 514 - <script> 515 - let currentTrackId = null; 516 - 517 - async function loadTracks() { 518 - const response = await fetch('/tracks'); 519 - const data = await response.json(); 520 - 521 - const tracksDiv = document.getElementById('tracks'); 522 - tracksDiv.innerHTML = ''; 523 - 524 - if (data.tracks.length === 0) { 525 - tracksDiv.innerHTML = ` 526 - <div class="empty-state"> 527 - <h2>no tracks yet</h2> 528 - <p>be the first to share music on relay</p> 529 - </div> 530 - `; 531 - return; 532 - } 533 - 534 - data.tracks.forEach(track => { 535 - const trackDiv = document.createElement('div'); 536 - trackDiv.className = 'track'; 537 - if (currentTrackId === track.id) { 538 - trackDiv.classList.add('playing'); 539 - } 540 - trackDiv.innerHTML = ` 541 - <div class="track-title">${track.title}</div> 542 - <div class="track-artist">${track.artist}${track.album ? ' - ' + track.album : ''}</div> 543 - `; 544 - trackDiv.onclick = () => playTrack(track); 545 - tracksDiv.appendChild(trackDiv); 546 - }); 547 - } 548 - 549 - function playTrack(track) { 550 - currentTrackId = track.id; 551 - 552 - const player = document.getElementById('player'); 553 - const audio = document.getElementById('audio'); 554 - const title = document.getElementById('playerTitle'); 555 - const artist = document.getElementById('playerArtist'); 556 - 557 - title.textContent = track.title; 558 - artist.textContent = track.artist + (track.album ? ' - ' + track.album : ''); 559 - audio.src = `/audio/${track.file_id}`; 560 - 561 - player.classList.add('active'); 562 - audio.play(); 563 - 564 - loadTracks(); // refresh to show playing state 565 - } 566 - 567 - // load tracks on page load 568 - loadTracks(); 569 - </script> 570 - </body> 571 - </html> 572 - """
+52 -30
src/relay/auth.py
··· 1 - """authentication and session management.""" 1 + """OAuth 2.1 authentication and session management.""" 2 2 3 3 import secrets 4 4 from dataclasses import dataclass 5 5 from typing import Annotated 6 6 7 - from atproto import Client, IdResolver 7 + from atproto_oauth import OAuthClient 8 + from atproto_oauth.stores.memory import MemorySessionStore, MemoryStateStore 8 9 from fastapi import Cookie, HTTPException 9 10 11 + from relay.config import settings 12 + 10 13 11 14 @dataclass 12 15 class Session: ··· 15 18 session_id: str 16 19 did: str 17 20 handle: str 21 + oauth_session: dict # store OAuth session data 18 22 19 23 20 - # in-memory session store (MVP - replace with redis/db later) 24 + # in-memory stores (MVP - replace with redis/db later) 21 25 _sessions: dict[str, Session] = {} 26 + _state_store = MemoryStateStore() 27 + _session_store = MemorySessionStore() 28 + 29 + # OAuth client 30 + oauth_client = OAuthClient( 31 + client_id=settings.atproto_client_id, 32 + redirect_uri=settings.atproto_redirect_uri, 33 + scope="atproto", 34 + state_store=_state_store, 35 + session_store=_session_store, 36 + ) 22 37 23 38 24 - def create_session(did: str, handle: str) -> str: 39 + def create_session(did: str, handle: str, oauth_session: dict) -> str: 25 40 """create a new session for authenticated user.""" 26 41 session_id = secrets.token_urlsafe(32) 27 - _sessions[session_id] = Session(session_id=session_id, did=did, handle=handle) 42 + _sessions[session_id] = Session( 43 + session_id=session_id, 44 + did=did, 45 + handle=handle, 46 + oauth_session=oauth_session, 47 + ) 28 48 return session_id 29 49 30 50 ··· 38 58 _sessions.pop(session_id, None) 39 59 40 60 41 - def verify_app_password(handle: str, app_password: str) -> tuple[str, str]: 42 - """verify atproto app password and return (did, handle).""" 61 + async def start_oauth_flow(handle: str) -> tuple[str, str]: 62 + """start OAuth flow and return (auth_url, state).""" 43 63 try: 44 - # resolve handle to DID, then get DID document to find PDS 45 - resolver = IdResolver() 46 - 47 - # first resolve handle to DID 48 - did = resolver.handle.resolve(handle) 49 - 50 - # then get DID document 51 - did_doc = resolver.did.resolve(did) 64 + auth_url, state = await oauth_client.start_authorization(handle) 65 + return auth_url, state 66 + except Exception as e: 67 + raise HTTPException( 68 + status_code=400, 69 + detail=f"failed to start OAuth flow: {e}", 70 + ) from e 52 71 53 - # find PDS service endpoint from DID document 54 - pds_url = None 55 - if hasattr(did_doc, "service") and did_doc.service: 56 - for service in did_doc.service: 57 - if service.id == "#atproto_pds": 58 - pds_url = service.service_endpoint 59 - break 60 72 61 - if not pds_url: 62 - raise ValueError("no PDS service found for this handle") 63 - 64 - # create client with user's actual PDS 65 - client = Client(base_url=pds_url) 66 - profile = client.login(handle, app_password) 67 - return profile.did, profile.handle 73 + async def handle_oauth_callback(code: str, state: str, iss: str) -> tuple[str, str, dict]: 74 + """handle OAuth callback and return (did, handle, oauth_session).""" 75 + try: 76 + oauth_session = await oauth_client.handle_callback( 77 + code=code, 78 + state=state, 79 + iss=iss, 80 + ) 81 + # OAuth session is already stored in session_store, just extract key info 82 + session_data = { 83 + "did": oauth_session.did, 84 + "handle": oauth_session.handle, 85 + "pds_url": oauth_session.pds_url, 86 + "authserver_iss": oauth_session.authserver_iss, 87 + "scope": oauth_session.scope, 88 + } 89 + return oauth_session.did, oauth_session.handle, session_data 68 90 except Exception as e: 69 91 raise HTTPException( 70 92 status_code=401, 71 - detail=f"authentication failed: {e}", 93 + detail=f"OAuth callback failed: {e}", 72 94 ) from e 73 95 74 96
+21 -2
src/relay/main.py
··· 6 6 from fastapi import FastAPI 7 7 from fastapi.middleware.cors import CORSMiddleware 8 8 9 - from relay.api import audio_router, auth_router, frontend_router, tracks_router 9 + from relay.api import audio_router, auth_router, tracks_router 10 10 from relay.config import settings 11 11 from relay.models import init_db 12 12 ··· 39 39 app.include_router(auth_router) 40 40 app.include_router(tracks_router) 41 41 app.include_router(audio_router) 42 - app.include_router(frontend_router) # include last so / route takes precedence 43 42 44 43 45 44 @app.get("/health") 46 45 async def health() -> dict[str, str]: 47 46 """health check endpoint.""" 48 47 return {"status": "ok"} 48 + 49 + 50 + @app.get("/client-metadata.json") 51 + async def client_metadata() -> dict: 52 + """serve OAuth client metadata.""" 53 + # Extract base URL from client_id for client_uri 54 + client_uri = settings.atproto_client_id.replace("/client-metadata.json", "") 55 + 56 + return { 57 + "client_id": settings.atproto_client_id, 58 + "client_name": "relay", 59 + "client_uri": client_uri, 60 + "redirect_uris": [settings.atproto_redirect_uri], 61 + "scope": "atproto", 62 + "grant_types": ["authorization_code", "refresh_token"], 63 + "response_types": ["code"], 64 + "token_endpoint_auth_method": "none", 65 + "application_type": "web", 66 + "dpop_bound_access_tokens": True, 67 + }
tests/__init__.py

This is a binary file and will not be displayed.

tests/conftest.py

This is a binary file and will not be displayed.

+3 -7
uv.lock
··· 100 100 101 101 [[package]] 102 102 name = "atproto" 103 - version = "0.0.63" 104 - source = { registry = "https://pypi.org/simple" } 103 + version = "0.0.1.dev460" 104 + source = { git = "https://github.com/zzstoatzz/atproto?rev=main#efe6f7407ae7edb7f35b410a1eb8dba7a5c63e7f" } 105 105 dependencies = [ 106 106 { name = "click" }, 107 107 { name = "cryptography" }, ··· 111 111 { name = "pydantic" }, 112 112 { name = "typing-extensions" }, 113 113 { name = "websockets" }, 114 - ] 115 - sdist = { url = "https://files.pythonhosted.org/packages/c1/ab/4b69c283091cc6687d4f58d4951076a37302f414e6ba258b616cc9788b5d/atproto-0.0.63.tar.gz", hash = "sha256:7a2328d7318771db4225ec61b23a85e70fcae0cd60208ffe9fcf8f637ca20cc0", size = 204207, upload-time = "2025-10-22T19:56:05.075Z" } 116 - wheels = [ 117 - { url = "https://files.pythonhosted.org/packages/cd/6f/b6a9f79953314a08c45550775fba7864444243a1c4cdd660d90477480d74/atproto-0.0.63-py3-none-any.whl", hash = "sha256:bc08a8a90cf043246ead252a59e39055b170cd82f2f5cd7c0243af95d021ac4b", size = 431894, upload-time = "2025-10-22T19:56:02.91Z" }, 118 114 ] 119 115 120 116 [[package]] ··· 1535 1531 requires-dist = [ 1536 1532 { name = "alembic", specifier = ">=1.14.0" }, 1537 1533 { name = "asyncpg", specifier = ">=0.30.0" }, 1538 - { name = "atproto", specifier = ">=0.0.55" }, 1534 + { name = "atproto", git = "https://github.com/zzstoatzz/atproto?rev=main" }, 1539 1535 { name = "boto3", specifier = ">=1.37.0" }, 1540 1536 { name = "fastapi", specifier = ">=0.115.0" }, 1541 1537 { name = "httpx", specifier = ">=0.28.0" },