Podcasts hosted on ATProto

Store podcast settings in AT Protocol for persistence

Problem:
- Settings were stored in local file system (podcast-metadata.json)
- File system is ephemeral on Railway - resets on each deployment
- Settings were lost when container restarted

Solution:
- Store settings as AT Protocol record (app.podcast.settings collection)
- Fixed rkey 'self' ensures one settings record per user
- Settings now persist permanently in decentralized storage
- Local file remains as fallback cache

Changes:
- POST /api/feed/metadata now requires auth and stores to ATProto
- GET /api/feed/metadata reads from ATProto first, falls back to local
- RSS generation fetches settings from ATProto
- Settings are truly owned by user and portable

Benefits:
- Settings persist across deployments
- Truly decentralized - user owns their data
- No database needed
- Automatic backup via ATProto

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

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

Changed files
+100 -6
src
routes
+100 -6
src/routes/feed.js
··· 12 12 * Fetches episodes from ATProto collection 13 13 */ 14 14 async function generateRSSXML(baseUrl, userDid, rpc) { 15 - const feedMeta = await getFeedMetadata(); 15 + // Try to fetch metadata from AT Protocol first 16 + let feedMeta; 17 + try { 18 + const settingsResult = await ok( 19 + rpc.get('com.atproto.repo.getRecord', { 20 + params: { 21 + repo: userDid, 22 + collection: 'app.podcast.settings', 23 + rkey: 'self' 24 + } 25 + }) 26 + ); 27 + 28 + if (settingsResult.value) { 29 + feedMeta = { 30 + title: settingsResult.value.title, 31 + description: settingsResult.value.description, 32 + link: settingsResult.value.link, 33 + language: settingsResult.value.language 34 + }; 35 + } else { 36 + // Fall back to local storage 37 + feedMeta = await getFeedMetadata(); 38 + } 39 + } catch (error) { 40 + // If not found in ATProto, use local fallback 41 + console.log('Settings not found in AT Protocol for RSS generation, using fallback'); 42 + feedMeta = await getFeedMetadata(); 43 + } 16 44 17 45 // Fetch episodes from ATProto collection 18 46 const result = await ok( ··· 346 374 /** 347 375 * Update feed metadata 348 376 * POST /api/feed/metadata 377 + * Stores metadata in AT Protocol as app.podcast.settings record 349 378 */ 350 - router.post('/metadata', async (req, res) => { 379 + router.post('/metadata', requireAuth, async (req, res) => { 351 380 try { 352 381 const { title, description, link, language } = req.body; 353 - 382 + const userDid = req.oauthSession.did; 383 + 354 384 const feedData = { 355 385 title: title || 'My AT Protocol Podcast', 356 386 description: description || 'A podcast hosted on AT Protocol', 357 387 link: link || 'https://example.com', 358 388 language: language || 'en' 359 389 }; 360 - 390 + 391 + // Create atcute Client with OAuth session's authenticated fetch handler 392 + const rpc = new Client({ 393 + handler: (pathname, init) => { 394 + return req.oauthSession.fetchHandler(pathname, init); 395 + } 396 + }); 397 + 398 + // Store settings in AT Protocol as a record 399 + await ok( 400 + rpc.post('com.atproto.repo.putRecord', { 401 + input: { 402 + repo: userDid, 403 + collection: 'app.podcast.settings', 404 + rkey: 'self', // Fixed key so there's only one settings record 405 + record: { 406 + $type: 'app.podcast.settings', 407 + ...feedData, 408 + updatedAt: new Date().toISOString() 409 + } 410 + } 411 + }) 412 + ); 413 + 414 + // Also store locally as fallback 361 415 await storeFeedMetadata(feedData); 362 - 416 + 363 417 res.json({ success: true, feed: feedData }); 364 418 } catch (error) { 365 419 console.error('Feed metadata error:', error); ··· 370 424 /** 371 425 * Get feed metadata 372 426 * GET /api/feed/metadata 427 + * Reads from AT Protocol if authenticated, falls back to local storage 373 428 */ 374 - router.get('/metadata', async (req, res) => { 429 + router.get('/metadata', requireAuth, async (req, res) => { 375 430 try { 431 + const userDid = req.oauthSession.did; 432 + 433 + // Try to fetch from AT Protocol first 434 + try { 435 + const rpc = new Client({ 436 + handler: (pathname, init) => { 437 + return req.oauthSession.fetchHandler(pathname, init); 438 + } 439 + }); 440 + 441 + const result = await ok( 442 + rpc.get('com.atproto.repo.getRecord', { 443 + params: { 444 + repo: userDid, 445 + collection: 'app.podcast.settings', 446 + rkey: 'self' 447 + } 448 + }) 449 + ); 450 + 451 + if (result.value) { 452 + const feedMeta = { 453 + title: result.value.title, 454 + description: result.value.description, 455 + link: result.value.link, 456 + language: result.value.language 457 + }; 458 + 459 + // Update local cache 460 + await storeFeedMetadata(feedMeta); 461 + 462 + return res.json(feedMeta); 463 + } 464 + } catch (atprotoError) { 465 + // If not found in ATProto, fall through to local storage 466 + console.log('Settings not found in AT Protocol, using local fallback'); 467 + } 468 + 469 + // Fallback to local storage 376 470 const feedMeta = await getFeedMetadata(); 377 471 res.json(feedMeta); 378 472 } catch (error) {