a simple pds frontend for listing accounts and generating invite codes
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

add bsky compatiable cdn env option

this also puts all of the env variables into a single file so it's easier to see what options can be configured

Winter fecb9e86 f33894b8

+47 -35
+3 -8
CLAUDE.md
··· 20 20 21 21 **data flow:** 22 22 23 - - `+page.server.ts` — SSR only. calls `com.atproto.sync.listRepos` (unauthenticated, max 1000), then `Promise.allSettled` over all active DIDs doing parallel `describeRepo` + `getRecord` fetches. avatar URLs use `https://blobs.blue/{did}/avatar@webp` (no CID parsing needed). 23 + - `+page.server.ts` — SSR only. calls `com.atproto.sync.listRepos` (unauthenticated, paginated) filtering out deactivated/takendown repos, then `Promise.allSettled` over remaining DIDs doing parallel `describeRepo` + `getRecord` fetches. avatar URLs use the bsky cdn format (`{CDN_URL}/img/avatar/plain/{did}/{cid}@jpeg`). 24 24 - `api/invite/+server.ts` — POST endpoint. in-memory IP rate limit (`Map<string, number[]>`), then raw `fetch` to the PDS using Basic auth (`admin:{PDS_ADMIN_PASSWORD}`). rate limiting is disabled in `dev` mode. 25 25 - `+page.svelte` — client component. invite code generation, clipboard copy, error display. pdsmoover link at bottom. 26 26 27 - **env vars** (all read via `$env/dynamic/private` — resolved at runtime, never baked into bundles): 27 + **env vars** — all centralized in `$lib/server/env.ts` (read via `$env/dynamic/private`, resolved at runtime, never baked into bundles). see that file for defaults and docs. 28 28 29 - - `PDS_URL` — required for all PDS calls 30 - - `PDS_ADMIN_PASSWORD` — required for invite code generation 31 - - `INVITE_RATE_LIMIT` — codes per IP per window (default 3) 32 - - `INVITE_RATE_WINDOW` — window in seconds (default 86400) 33 - 34 - **key constraint:** `$lib/server/` is enforced server-only by SvelteKit. never use `process.env` directly for secrets — use `$env/dynamic/private`. never import from `$lib/server/` in `+page.svelte` or any client-accessible module. 29 + **key constraint:** `$lib/server/` is enforced server-only by SvelteKit. never use `process.env` directly for secrets — import from `$lib/server/env`. never import from `$lib/server/` in `+page.svelte` or any client-accessible module. 35 30 36 31 **styling:** ported from `../site/` — Host Grotesk variable font, oklch color vars (`--color-bg`, `--color-surface`, `--color-fg`), dark mode via `prefers-color-scheme`. pds.ls button icon uses CSS `mask-image` over a vectorized SVG (`static/pdsls-icon.svg`, generated with potrace from the upstream PNG).
+16
src/lib/server/env.ts
··· 1 + import { env } from '$env/dynamic/private'; 2 + 3 + // PDS base URL for all atproto API calls 4 + export const PDS_URL = env.PDS_URL ?? 'https://pds.madoka.systems'; 5 + 6 + // PDS admin password for privileged operations (invite code generation) 7 + export const PDS_ADMIN_PASSWORD = env.PDS_ADMIN_PASSWORD; 8 + 9 + // bsky-compatible image CDN base URL for avatar proxying 10 + export const CDN_URL = env.CDN_URL ?? 'https://cdn.madoka.systems'; 11 + 12 + // max invite codes a single IP can generate per window 13 + export const INVITE_RATE_LIMIT = parseInt(env.INVITE_RATE_LIMIT ?? '3', 10) || 3; 14 + 15 + // rate limit window in ms (env var is in seconds, default 86400 = 24h) 16 + export const INVITE_RATE_WINDOW = (parseInt(env.INVITE_RATE_WINDOW ?? '86400', 10) || 86400) * 1000;
+1 -3
src/lib/server/pds.ts
··· 1 1 import { Client, simpleFetchHandler } from '@atcute/client'; 2 - import { env } from '$env/dynamic/private'; 3 - 4 - const PDS_URL = env.PDS_URL ?? 'https://pds.madoka.systems'; 2 + import { PDS_URL } from './env'; 5 3 6 4 export const publicRpc = new Client({ 7 5 handler: simpleFetchHandler({ service: PDS_URL }),
+9 -2
src/routes/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 2 import { publicRpc } from '$lib/server/pds'; 3 + import { CDN_URL } from '$lib/server/env'; 3 4 4 5 const DID_RE = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; 5 6 ··· 7 8 did: string; 8 9 handle: string; 9 10 displayName: string | null; 10 - avatarUrl: string; 11 + avatarUrl: string | null; 11 12 } 12 13 13 14 const LIMIT = 1000; ··· 69 70 : repo.did; 70 71 71 72 let displayName: string | null = null; 73 + let avatarUrl: string | null = null; 72 74 if (profileResult.status === 'fulfilled') { 73 75 const value = profileResult.value.data.value as Record<string, unknown>; 74 76 if (typeof value.displayName === 'string' && value.displayName.trim()) { 75 77 displayName = value.displayName.trim().slice(0, 640); 76 78 } 79 + const avatar = value.avatar as { ref?: { $link?: string } } | undefined; 80 + const cid = avatar?.ref?.$link; 81 + if (cid) { 82 + avatarUrl = `${CDN_URL}/img/avatar/plain/${repo.did}/${cid}@jpeg`; 83 + } 77 84 } 78 85 79 86 return { 80 87 did: repo.did, 81 88 handle, 82 89 displayName, 83 - avatarUrl: `https://blobs.blue/${repo.did}/avatar@webp`, 90 + avatarUrl, 84 91 } satisfies Account; 85 92 }) 86 93 );
+11 -9
src/routes/+page.svelte
··· 71 71 <ul class="accounts"> 72 72 {#each visible as account (account.did)} 73 73 <li class="account"> 74 - <img 75 - class="avatar" 76 - src={account.avatarUrl} 77 - alt="" 78 - width="48" 79 - height="48" 80 - loading="lazy" 81 - onerror={(e) => ((e.currentTarget as HTMLImageElement).style.visibility = "hidden")} 82 - /> 74 + {#if account.avatarUrl} 75 + <img 76 + class="avatar" 77 + src={account.avatarUrl} 78 + alt="" 79 + width="48" 80 + height="48" 81 + loading="lazy" 82 + onerror={(e) => ((e.currentTarget as HTMLImageElement).style.visibility = "hidden")} 83 + /> 84 + {/if} 83 85 <div class="account-info"> 84 86 {#if account.displayName} 85 87 <span class="display-name">{account.displayName}</span>
+7 -13
src/routes/api/invite/+server.ts
··· 1 1 import type { RequestHandler } from './$types'; 2 2 import { json } from '@sveltejs/kit'; 3 3 import { dev } from '$app/environment'; 4 - import { env } from '$env/dynamic/private'; 5 - 6 - const RATE_LIMIT = parseInt(env.INVITE_RATE_LIMIT ?? '3', 10) || 3; 7 - const RATE_WINDOW = (parseInt(env.INVITE_RATE_WINDOW ?? '86400', 10) || 86400) * 1000; 4 + import { PDS_URL, PDS_ADMIN_PASSWORD, INVITE_RATE_LIMIT, INVITE_RATE_WINDOW } from '$lib/server/env'; 8 5 9 6 const ipTimestamps = new Map<string, number[]>(); 10 7 ··· 16 13 } 17 14 18 15 export const POST: RequestHandler = async ({ request }) => { 19 - const pdsUrl = env.PDS_URL; 20 - const adminPassword = env.PDS_ADMIN_PASSWORD; 21 - 22 - if (!pdsUrl || !adminPassword) { 16 + if (!PDS_URL || !PDS_ADMIN_PASSWORD) { 23 17 return json({ error: 'server not configured (missing PDS_URL or PDS_ADMIN_PASSWORD)' }, { status: 503 }); 24 18 } 25 19 ··· 27 21 const now = Date.now(); 28 22 29 23 const timestamps = (ipTimestamps.get(ip) ?? []).filter( 30 - (t) => now - t < RATE_WINDOW 24 + (t) => now - t < INVITE_RATE_WINDOW 31 25 ); 32 26 33 - if (!dev && timestamps.length >= RATE_LIMIT) { 27 + if (!dev && timestamps.length >= INVITE_RATE_LIMIT) { 34 28 const oldest = Math.min(...timestamps); 35 - const retryAfter = Math.ceil((oldest + RATE_WINDOW - now) / 1000); 29 + const retryAfter = Math.ceil((oldest + INVITE_RATE_WINDOW - now) / 1000); 36 30 return new Response(JSON.stringify({ error: 'rate limited' }), { 37 31 status: 429, 38 32 headers: { ··· 43 37 } 44 38 45 39 try { 46 - const credentials = Buffer.from(`admin:${adminPassword}`).toString('base64'); 40 + const credentials = Buffer.from(`admin:${PDS_ADMIN_PASSWORD}`).toString('base64'); 47 41 const res = await fetch( 48 - new URL('/xrpc/com.atproto.server.createInviteCode', pdsUrl), 42 + new URL('/xrpc/com.atproto.server.createInviteCode', PDS_URL), 49 43 { 50 44 method: 'POST', 51 45 headers: {