[READ-ONLY] Mirror of https://github.com/flo-bit/svelte-cloudflare-statusphere. statusphere.atmo.tools
atproto cloudflare-workers oauth svelte
0

Configure Feed

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

randomize port, improve instructions

+205 -35
+121 -15
AGENT_SETUP.md
··· 8 8 - SvelteKit with `@sveltejs/adapter-cloudflare` 9 9 - A `wrangler.jsonc` (or `wrangler.toml`) config 10 10 11 - ## Step 0: Ask the user 11 + ## Step 0: Understand the project and propose an integration approach 12 + 13 + Before asking configuration questions, **explore the existing codebase** to understand what the app does. Read the main pages, components, state management, and data flow. Then propose to the user *how* AT Protocol should integrate with their app. 12 14 13 - Before making any changes, ask the user these questions: 15 + ### What to look for 16 + 17 + - **User data**: localStorage, cookies, IndexedDB, database calls, API state — anything per-user that could live on a PDS instead. 18 + - **Content creation**: Does the app let users create, edit, or save things? (posts, notes, drawings, settings, lists, bookmarks, etc.) 19 + - **Existing routes**: How is the app structured? Would user-specific public pages make sense? 20 + 21 + ### Common integration patterns 22 + 23 + Based on what you find, suggest the relevant patterns to the user: 24 + 25 + 1. **Replace localStorage/local data with PDS records** — If the app stores per-user data locally (localStorage, IndexedDB, cookies), store it as AT Protocol records on the user's PDS instead. The data becomes portable and accessible from any app that speaks AT Protocol. **Important: data on a PDS is public.** Make this very clear to the user — if the app currently stores private data locally, moving it to a PDS makes it visible to anyone. Only suggest this for data the user would be comfortable sharing publicly. 26 + 27 + 2. **Add public `/{actor}` profile routes** — If each user produces content, add a route like `/{actor}` (where `actor` is a handle like `alice.bsky.social` or a DID) that displays that user's records. The logged-in user manages their own data from the main UI; anyone can view anyone else's public data by visiting their route. This is a natural fit when replacing localStorage — what was private per-device data becomes a public, shareable profile. 28 + 29 + 3. **Social interactions** — Add likes, reposts, follows, or other social features using existing AT Protocol / Bluesky lexicons (`app.bsky.feed.like`, `app.bsky.graph.follow`, etc.). 30 + 31 + 4. **Content publishing** — If the app produces content (posts, articles, images, media), publish to the user's PDS under a custom collection namespace. 32 + 33 + 5. **Auth only** — Just add login/logout. The app doesn't read or write AT Protocol records — it only needs to know who the user is. 34 + 35 + Present your analysis and a concrete proposal (e.g. "I'd suggest replacing the localStorage todos with records in a `xyz.yourdomain.todo` collection, and adding a `/{actor}` route so users can share their list publicly"). **Wait for the user's response** before continuing to Step 1. 36 + 37 + ## Step 1: Ask configuration questions 38 + 39 + Once the integration approach is agreed on, ask the user: 14 40 15 41 1. **UI**: Should I add a login UI? 16 42 - **`foxui`** — Use `@foxui/social` login modal (polished, recommended) 17 43 - **`basic`** — Simple login/logout page at `/user` route (uses Tailwind if available) 18 44 - **`none`** — Backend only, no UI (you'll build your own) 19 45 20 - 2. **Collections**: What AT Protocol collections should your app write to? (e.g. `xyz.statusphere.status`, `app.bsky.feed.like`). Leave empty for read-only. 46 + 2. **Collections**: What AT Protocol collections should your app write to? (e.g. `xyz.statusphere.status`, `app.bsky.feed.like`). Leave empty for read-only. (You should already have a suggestion from Step 0.) 21 47 22 48 3. **Blobs**: Does the app need to upload blobs (images, video)? If yes, what types? (e.g. `image/*`, `video/*`) 23 49 ··· 30 56 31 57 Use the answers to customize `settings.ts` (marked with `CUSTOMIZE` below) and choose which UI dependencies/files to create. 32 58 33 - ## Step 1: Install dependencies 59 + ## Step 2: Install dependencies 34 60 35 61 Always install: 36 62 ··· 45 71 pnpm add @foxui/social @foxui/core 46 72 ``` 47 73 48 - ## Step 2: Create files 74 + ## Step 3: Create files 49 75 50 76 ### Download all files 51 77 ··· 58 84 "src/lib/atproto/auth.svelte.ts" 59 85 "src/lib/atproto/methods.ts" 60 86 "src/lib/atproto/image-helper.ts" 87 + "src/lib/atproto/port.ts" 61 88 "src/lib/atproto/index.ts" 62 89 "src/lib/atproto/server/signed-cookie.ts" 63 90 "src/lib/atproto/server/kv-store.ts" ··· 69 96 "src/lib/atproto/scripts/generate-key.ts" 70 97 "src/lib/atproto/scripts/generate-secret.ts" 71 98 "src/lib/atproto/scripts/setup-dev.ts" 99 + "src/lib/atproto/scripts/tunnel.ts" 72 100 "src/routes/(oauth)/oauth/callback/+server.ts" 73 101 "src/routes/(oauth)/oauth/jwks.json/+server.ts" 74 102 "src/routes/(oauth)/oauth-client-metadata.json/+server.ts" ··· 114 142 export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query'; 115 143 ``` 116 144 117 - ## Step 3: Modify existing files 145 + ## Step 4: Modify existing files 118 146 119 147 ### `src/app.d.ts` 120 148 ··· 332 360 333 361 ### `vite.config.ts` 334 362 335 - Add dev server config for loopback OAuth: 363 + Add the port import and dev server config for loopback OAuth: 364 + 365 + ```ts 366 + import { DEV_PORT } from './src/lib/atproto/port'; 367 + ``` 336 368 337 369 ```ts 338 370 server: { 339 371 host: '127.0.0.1', 340 - port: 5183 372 + port: DEV_PORT 341 373 } 342 374 ``` 343 375 344 - Add this inside `defineConfig()`. Do not remove existing plugins or config. 376 + Add these inside `defineConfig()`. Do not remove existing plugins or config. 345 377 346 378 ### `wrangler.jsonc` 347 379 348 380 Add or merge these fields: 349 381 382 + - Ensure `"main"` is set to the SvelteKit Cloudflare worker entrypoint: 383 + 384 + ```jsonc 385 + "main": ".svelte-kit/cloudflare/_worker.js" 386 + ``` 387 + 388 + - Ensure `"assets"` is configured: 389 + 390 + ```jsonc 391 + "assets": { 392 + "binding": "ASSETS", 393 + "directory": ".svelte-kit/cloudflare" 394 + } 395 + ``` 396 + 350 397 - Add `"nodejs_compat_v2"` to `compatibility_flags` (create the array if it doesn't exist) 351 398 - Do NOT add `OAUTH_PUBLIC_URL` to vars — it is only needed for production deployment and the user will set it themselves later. In dev mode without it, the app uses a loopback client automatically. 352 - - Add KV namespace placeholders to `kv_namespaces`: 399 + - Add KV namespace placeholders to `kv_namespaces`. Use the project name (from the `"name"` field in `wrangler.jsonc` or `package.json`) as a prefix so namespaces are distinguishable when multiple projects share the same Cloudflare account: 353 400 354 401 ```jsonc 355 402 { "binding": "OAUTH_SESSIONS", "id": "TODO" }, ··· 369 416 ```json 370 417 "env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts", 371 418 "env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts", 372 - "env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts" 419 + "env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts", 420 + "tunnel": "npx tsx src/lib/atproto/scripts/tunnel.ts" 373 421 ``` 374 422 375 423 ### `.gitignore` ··· 382 430 !.env.example 383 431 ``` 384 432 385 - ## Step 4: Run setup and verify 433 + ## Step 5: Run setup and verify 386 434 387 - 1. Run `pnpm env:setup-dev` to generate secrets in `.env` 435 + 1. Run `pnpm env:setup-dev` to generate secrets in `.env` and assign a random dev port (5200–7200) in `port.ts` 388 436 2. Run `pnpm dev` to start the dev server 389 - 3. Verify it starts on `http://127.0.0.1:5183` 437 + 3. Verify it starts on `http://127.0.0.1:<DEV_PORT>` (the port from `port.ts`) 390 438 4. Tell the user: 391 439 - Dev mode uses a loopback client (no keys needed) 392 - - For production: create KV namespaces with `npx wrangler kv namespace create OAUTH_SESSIONS` and `OAUTH_STATES`, update the IDs in `wrangler.jsonc`, set `OAUTH_PUBLIC_URL` to their domain, and run `npx wrangler secret put CLIENT_ASSERTION_KEY` / `COOKIE_SECRET` with values from `pnpm env:generate-key` / `pnpm env:generate-secret` 440 + - For production: create KV namespaces prefixed with the project name — e.g. `npx wrangler kv namespace create <project-name>-OAUTH_SESSIONS` and `<project-name>-OAUTH_STATES` (use the `name` from `wrangler.jsonc`). Update the IDs in `wrangler.jsonc`, set `OAUTH_PUBLIC_URL` to their domain, and run `npx wrangler secret put CLIENT_ASSERTION_KEY` / `COOKIE_SECRET` with values from `pnpm env:generate-key` / `pnpm env:generate-secret` 441 + 442 + ## Tunnel (for testing OAuth with a public URL) 443 + 444 + In dev mode, OAuth works via loopback (no public URL needed). But if you need to test with a real public URL (e.g. testing from another device, or testing production-like OAuth flows), use the tunnel command: 445 + 446 + ```sh 447 + pnpm tunnel 448 + ``` 449 + 450 + This requires `cloudflared` to be installed. It: 451 + 1. Spawns a Cloudflare Quick Tunnel pointing to `http://localhost:<DEV_PORT>` 452 + 2. Sets `OAUTH_PUBLIC_URL` in `.env` to the tunnel URL 453 + 3. Adds the tunnel hostname to `allowedHosts` in `vite.config.ts` 454 + 4. Shows a persistent status bar with the tunnel URL 455 + 456 + After starting the tunnel, restart the dev server (`pnpm dev`) to pick up the new URL. When you stop the tunnel (Ctrl+C), it automatically cleans up `.env` and `vite.config.ts`. 393 457 394 458 ## Usage examples 395 459 ··· 422 486 await deleteRecord({ collection: 'your.collection.name', rkey: 'some-key' }); 423 487 424 488 const blob = await uploadBlob({ blob: file }); 489 + // For images, returns: { $type: 'blob', ref: { $link: string }, mimeType: string, size: number, aspectRatio: { width, height } } 490 + // aspectRatio is auto-detected for image/* blobs, or you can pass it explicitly: 491 + // await uploadBlob({ blob: file, aspectRatio: { width: 2000, height: 1144 } }) 492 + 493 + // Store the blob reference in a record 494 + await putRecord({ 495 + collection: 'your.collection.name', 496 + rkey: createTID(), 497 + record: { image: blob, createdAt: new Date().toISOString() } 498 + }); 499 + ``` 500 + 501 + ### Displaying blobs 502 + 503 + ```ts 504 + import { getBlobURL, getCDNImageBlobUrl } from '$lib/atproto'; 505 + 506 + // Option 1: Direct PDS URL (works for any blob type) 507 + const url = await getBlobURL({ did: 'did:plc:...', blob: record.image }); 508 + 509 + // Option 2: Bluesky CDN URL (faster, cached, image-only, returns WebP thumbnails) 510 + const cdnUrl = getCDNImageBlobUrl({ did: 'did:plc:...', blob: record.image }); 511 + ``` 512 + 513 + ### Image compression helper 514 + 515 + The `image-helper.ts` module provides utilities for working with image blobs: 516 + 517 + ```ts 518 + import { compressImage, checkAndUploadImage, getImageFromRecord } from '$lib/atproto/image-helper'; 519 + 520 + // Compress an image before uploading (default: max 900KB, max 2048px dimension, outputs WebP) 521 + const { blob: compressedBlob, aspectRatio } = await compressImage(file); 522 + const uploaded = await uploadBlob({ blob: compressedBlob }); 523 + 524 + // Or use checkAndUploadImage to handle compression + upload in one step 525 + // Works with File objects, string URLs (with an image proxy), or already-uploaded blob refs 526 + const record = { image: someFileOrUrl }; 527 + await checkAndUploadImage(record, 'image', '/api/image-proxy?url='); 528 + 529 + // Get a display URL from a record's blob field (handles blob refs, object URLs, and strings) 530 + const displayUrl = getImageFromRecord(record, did, 'image'); 425 531 ``` 426 532 427 533 ### Read operations (no auth needed)
+10 -7
GETTING_STARTED.md
··· 13 13 pnpm dev 14 14 ``` 15 15 16 - That's it. In dev mode the app uses a loopback OAuth client — no keys, no Cloudflare setup, no secrets. It binds to `127.0.0.1:5183` (required for AT Protocol loopback OAuth). 16 + That's it. In dev mode the app uses a loopback OAuth client — no keys, no Cloudflare setup, no secrets. It binds to `127.0.0.1` on the port defined in `src/lib/atproto/port.ts` (required for AT Protocol loopback OAuth). 17 17 18 - Open http://127.0.0.1:5183 and log in with any Bluesky handle. 18 + Open the URL shown in the terminal and log in with any Bluesky handle. 19 19 20 20 ## Configure your app 21 21 ··· 35 35 36 36 ### 1. Create KV namespaces 37 37 38 + Prefix namespace names with your project name (the `name` field in `wrangler.jsonc`) so they're easy to identify when you have multiple projects on the same Cloudflare account: 39 + 38 40 ```sh 39 - npx wrangler kv namespace create OAUTH_SESSIONS 40 - npx wrangler kv namespace create OAUTH_STATES 41 + npx wrangler kv namespace create <project-name>-OAUTH_SESSIONS 42 + npx wrangler kv namespace create <project-name>-OAUTH_STATES 41 43 ``` 42 44 43 45 Each command outputs an ID. Put them in `wrangler.jsonc`: ··· 82 84 To test the full confidential client flow locally using [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/): 83 85 84 86 ```sh 85 - pnpm env:setup-dev # generates secrets in .env 86 - cloudflared tunnel --url http://localhost:5183 # start tunnel (note the URL it prints) 87 + pnpm env:setup-dev # generates secrets in .env + random port 88 + pnpm tunnel # start tunnel (uses port from port.ts) 87 89 ``` 88 90 89 91 Add the tunnel URL to `.env`: ··· 103 105 | `pnpm check` | Run svelte-check | 104 106 | `pnpm env:generate-key` | Generate client assertion key | 105 107 | `pnpm env:generate-secret` | Generate cookie signing secret | 106 - | `pnpm env:setup-dev` | Generate both secrets and write to `.env` | 108 + | `pnpm env:setup-dev` | Generate secrets, write to `.env`, assign random dev port | 109 + | `pnpm tunnel` | Start a Cloudflare tunnel for testing OAuth with a public URL | 107 110 108 111 ## Optional: Profile caching 109 112
+4 -2
README.md
··· 13 13 pnpm dev 14 14 ``` 15 15 16 - Dev mode uses a loopback OAuth client — no keys or Cloudflare setup needed. Open http://127.0.0.1:5183 and log in with any Bluesky handle. 16 + Dev mode uses a loopback OAuth client — no keys or Cloudflare setup needed. Open the URL shown in the terminal and log in with any Bluesky handle. (The port is randomized per project — see `src/lib/atproto/port.ts`.) 17 17 18 18 See [GETTING_STARTED.md](GETTING_STARTED.md) for production deployment, tunnel setup, and configuration. 19 19 ··· 37 37 ├── image-helper.ts # Image compression + upload helpers 38 38 ├── index.ts # Public exports 39 39 ├── methods.ts # AT Protocol helpers (read/write/resolve) 40 + ├── port.ts # Dev server port (randomized per project) 40 41 ├── settings.ts # Collections, scope, config constants 41 42 ├── server/ 42 43 │ ├── oauth.ts # OAuthClient factory (loopback vs confidential) ··· 49 50 └── scripts/ 50 51 ├── generate-key.ts 51 52 ├── generate-secret.ts 52 - └── setup-dev.ts 53 + ├── setup-dev.ts 54 + └── tunnel.ts 53 55 54 56 src/routes/(oauth)/ 55 57 ├── oauth/callback/+server.ts
+1 -1
SETUP.md
··· 270 270 271 271 1. `pnpm env:setup-dev` 272 272 2. Add tunnel URL to `.env`: `OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com` 273 - 3. `cloudflared tunnel --url http://localhost:5183` 273 + 3. `pnpm tunnel` 274 274 4. `pnpm dev` 275 275 276 276 Without `OAUTH_PUBLIC_URL`, dev mode uses a loopback public client (no keys needed).
+4 -4
src/lib/atproto/image-helper.ts
··· 114 114 } 115 115 116 116 const blob = await response.blob(); 117 - const compressedBlob = await compressImage(blob); 117 + const { blob: compressed, aspectRatio } = await compressImage(blob); 118 118 119 - recordWithImage[key] = await uploadBlob({ blob: compressedBlob.blob }); 119 + recordWithImage[key] = await uploadBlob({ blob: compressed, aspectRatio }); 120 120 121 121 return; 122 122 } ··· 125 125 if (recordWithImage[key].objectUrl) { 126 126 URL.revokeObjectURL(recordWithImage[key].objectUrl); 127 127 } 128 - const compressedBlob = await compressImage(recordWithImage[key].blob); 129 - recordWithImage[key] = await uploadBlob({ blob: compressedBlob.blob }); 128 + const { blob: compressed, aspectRatio } = await compressImage(recordWithImage[key].blob); 129 + recordWithImage[key] = await uploadBlob({ blob: compressed, aspectRatio }); 130 130 } 131 131 } 132 132
+42 -2
src/lib/atproto/methods.ts
··· 227 227 } 228 228 229 229 /** 230 + * Gets the dimensions of an image blob. 231 + */ 232 + function getImageDimensions(blob: Blob): Promise<{ width: number; height: number }> { 233 + return new Promise((resolve, reject) => { 234 + const img = new Image(); 235 + const url = URL.createObjectURL(blob); 236 + img.onload = () => { 237 + URL.revokeObjectURL(url); 238 + resolve({ width: img.naturalWidth, height: img.naturalHeight }); 239 + }; 240 + img.onerror = () => { 241 + URL.revokeObjectURL(url); 242 + reject(new Error('Failed to load image for dimensions')); 243 + }; 244 + img.src = url; 245 + }); 246 + } 247 + 248 + /** 230 249 * Uploads a blob via remote function. 231 250 * Converts the Blob to a byte array for serialization across the remote boundary. 251 + * For image blobs, automatically includes aspectRatio with width and height. 232 252 */ 233 - export async function uploadBlob({ blob }: { blob: Blob }) { 253 + export async function uploadBlob({ 254 + blob, 255 + aspectRatio 256 + }: { 257 + blob: Blob; 258 + aspectRatio?: { width: number; height: number }; 259 + }) { 234 260 if (!user.did) throw new Error("Can't upload blob: Not logged in"); 235 261 262 + // Auto-detect dimensions for image blobs if not provided 263 + if (!aspectRatio && blob.type.startsWith('image/')) { 264 + try { 265 + aspectRatio = await getImageDimensions(blob); 266 + } catch { 267 + // Non-critical — proceed without aspectRatio 268 + } 269 + } 270 + 236 271 const arrayBuffer = await blob.arrayBuffer(); 237 272 const bytes = Array.from(new Uint8Array(arrayBuffer)); 238 273 239 274 const { uploadBlob: uploadBlobRemote } = await import('./server/repo.remote'); 240 - return await uploadBlobRemote({ bytes, mimeType: blob.type || 'application/octet-stream' }); 275 + const result = await uploadBlobRemote({ bytes, mimeType: blob.type || 'application/octet-stream' }); 276 + 277 + if (aspectRatio) { 278 + return { ...result, aspectRatio }; 279 + } 280 + return result; 241 281 } 242 282 243 283 /**
+3
src/lib/atproto/port.ts
··· 1 + // Dev server port — generated by setup-dev, shared by vite, oauth, and tunnel. 2 + // Each project gets a unique random port (5200–7200) so multiple projects can run simultaneously. 3 + export const DEV_PORT = 5183;
+14 -1
src/lib/atproto/scripts/setup-dev.ts
··· 1 1 import { existsSync } from 'node:fs'; 2 2 import { copyFile, readFile, writeFile } from 'node:fs/promises'; 3 3 import { resolve } from 'node:path'; 4 - import { randomBytes } from 'node:crypto'; 4 + import { randomBytes, randomInt } from 'node:crypto'; 5 5 6 6 import { generateClientAssertionKey } from '@atcute/oauth-node-client'; 7 7 ··· 45 45 46 46 await writeFile(envPath, vars); 47 47 console.log(`updated ${envPath}`); 48 + 49 + // Generate a random dev port (5200–7200) so multiple projects can run simultaneously 50 + const portPath = resolve(cwd, 'src/lib/atproto/port.ts'); 51 + const portFile = await readFile(portPath, 'utf8'); 52 + const currentPort = portFile.match(/DEV_PORT\s*=\s*(\d+)/); 53 + if (currentPort && parseInt(currentPort[1]) === 5183) { 54 + const port = randomInt(5200, 7201); 55 + const updated = portFile.replace(/DEV_PORT\s*=\s*\d+/, `DEV_PORT = ${port}`); 56 + await writeFile(portPath, updated); 57 + console.log(`set DEV_PORT to ${port} in port.ts`); 58 + } else { 59 + console.log(`DEV_PORT already customized (${currentPort?.[1]}), skipping`); 60 + }
+2 -1
src/lib/atproto/scripts/tunnel.ts
··· 1 1 import { readFileSync, writeFileSync } from 'node:fs'; 2 2 import { resolve } from 'node:path'; 3 3 import { spawn } from 'node:child_process'; 4 + import { DEV_PORT } from '../port'; 4 5 5 6 const cwd = process.cwd(); 6 7 const envPath = resolve(cwd, '.env'); ··· 147 148 148 149 // ── main ───────────────────────────────────────────────────────── 149 150 150 - const child = spawn('cloudflared', ['tunnel', '--url', 'http://localhost:5183'], { 151 + const child = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${DEV_PORT}`], { 151 152 stdio: ['ignore', 'pipe', 'pipe'] 152 153 }); 153 154
+2 -1
src/lib/atproto/server/oauth.ts
··· 19 19 } from '@atcute/identity-resolver'; 20 20 import { KVStore } from './kv-store'; 21 21 import { DOH_RESOLVER, REDIRECT_PATH, scopes } from '../settings'; 22 + import { DEV_PORT } from '../port'; 22 23 import { dev } from '$app/environment'; 23 24 24 25 function createActorResolver() { ··· 62 63 // redirect_uris must use 127.0.0.1 (not localhost). 63 64 return new OAuthClient({ 64 65 metadata: { 65 - redirect_uris: [`http://127.0.0.1:5183${REDIRECT_PATH}`], 66 + redirect_uris: [`http://127.0.0.1:${DEV_PORT}${REDIRECT_PATH}`], 66 67 scope: scopes 67 68 }, 68 69 actorResolver,
+2 -1
vite.config.ts
··· 1 1 import tailwindcss from '@tailwindcss/vite'; 2 2 import { sveltekit } from '@sveltejs/kit/vite'; 3 3 import { defineConfig } from 'vite'; 4 + import { DEV_PORT } from './src/lib/atproto/port'; 4 5 5 6 export default defineConfig({ 6 7 plugins: [sveltekit(), tailwindcss()], 7 8 server: { 8 9 host: '127.0.0.1', 9 - port: 5183, 10 + port: DEV_PORT, 10 11 allowedHosts: [] 11 12 } 12 13 });