[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.

18 kB 540 lines
1# Add AT Protocol OAuth to SvelteKit + Cloudflare Workers 2 3You are adding AT Protocol OAuth authentication to an existing SvelteKit project deployed on Cloudflare Workers. This uses server-side OAuth with `@atcute/oauth-node-client`, Cloudflare KV for session storage, and SvelteKit remote functions. 4 5## Prerequisites 6 7The project must already use: 8- SvelteKit with `@sveltejs/adapter-cloudflare` 9- A `wrangler.jsonc` (or `wrangler.toml`) config 10 11## Step 0: Understand the project and propose an integration approach 12 13Before 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. 14 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 23Based on what you find, suggest the relevant patterns to the user: 24 251. **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 272. **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 293. **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 314. **Content publishing** — If the app produces content (posts, articles, images, media), publish to the user's PDS under a custom collection namespace. 32 335. **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 35Present 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 39Once the integration approach is agreed on, ask the user: 40 411. **UI**: Should I add a login UI? 42 - **`foxui`** — Use `@foxui/social` login modal (polished, recommended) 43 - **`basic`** — Simple login/logout page at `/user` route (uses Tailwind if available) 44 - **`none`** — Backend only, no UI (you'll build your own) 45 462. **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.) 47 483. **Blobs**: Does the app need to upload blobs (images, video)? If yes, what types? (e.g. `image/*`, `video/*`) 49 504. **Signup**: Should the app allow users to create new AT Protocol accounts (signup)? 51 - **`yes`** — Include a signup button/flow 52 - **`no`** — Login only, no account creation 53 545. **Production PDS**: Which PDS should be used for signup in production? (default: `https://selfhosted.social/`) 55 - Only relevant if signup is enabled. Skip if signup is `no`. 56 57Use the answers to customize `settings.ts` (marked with `CUSTOMIZE` below) and choose which UI dependencies/files to create. 58 59## Step 2: Install dependencies 60 61Always install: 62 63```sh 64pnpm add valibot 65pnpm add -D @atcute/oauth-node-client @atcute/identity-resolver @atcute/lexicons @atcute/client @atcute/tid @cloudflare/workers-types tsx @atcute/atproto @atcute/bluesky 66``` 67 68If UI choice is `foxui`: 69 70```sh 71pnpm add @foxui/social @foxui/core 72``` 73 74## Step 3: Create files 75 76### Download all files 77 78Run this bash script to download all required files into the project: 79 80```sh 81BASE_URL="https://raw.githubusercontent.com/flo-bit/svelte-cloudflare-statusphere/main" 82 83FILES=( 84 "src/lib/atproto/auth.svelte.ts" 85 "src/lib/atproto/methods.ts" 86 "src/lib/atproto/image-helper.ts" 87 "src/lib/atproto/port.ts" 88 "src/lib/atproto/index.ts" 89 "src/lib/atproto/server/signed-cookie.ts" 90 "src/lib/atproto/server/kv-store.ts" 91 "src/lib/atproto/server/oauth.ts" 92 "src/lib/atproto/server/oauth.remote.ts" 93 "src/lib/atproto/server/repo.remote.ts" 94 "src/lib/atproto/server/session.ts" 95 "src/lib/atproto/server/profile.ts" 96 "src/lib/atproto/scripts/generate-key.ts" 97 "src/lib/atproto/scripts/generate-secret.ts" 98 "src/lib/atproto/scripts/setup-dev.ts" 99 "src/lib/atproto/scripts/tunnel.ts" 100 "src/routes/(oauth)/oauth/callback/+server.ts" 101 "src/routes/(oauth)/oauth/jwks.json/+server.ts" 102 "src/routes/(oauth)/oauth-client-metadata.json/+server.ts" 103 ".env.example" 104) 105 106for file in "${FILES[@]}"; do 107 mkdir -p "$(dirname "$file")" 108 curl -fsSL "$BASE_URL/$file" -o "$file" 109 echo " downloaded $file" 110done 111``` 112 113### `src/lib/atproto/settings.ts` 114 115**Do not fetch this file.** Create it manually using the template below, customized with the user's answers: 116 117```ts 118import { dev } from '$app/environment'; 119import { scope } from '@atcute/oauth-node-client'; 120 121// CUSTOMIZE: writable collections 122export const collections = [] as const; 123 124export type AllowedCollection = (typeof collections)[number]; 125 126// CUSTOMIZE: OAuth scope — add scope.blob({ accept: ['image/*'] }), scope.rpc(), etc. as needed 127export const scopes = ['atproto', scope.repo({ collection: [...collections] })]; 128 129// CUSTOMIZE: set to true to allow signup, false for login-only 130export const ALLOW_SIGNUP = true; 131 132// CUSTOMIZE: PDS to use for signup (only relevant if ALLOW_SIGNUP is true) 133const devPDS = 'https://bsky.social/'; 134const prodPDS = 'https://selfhosted.social/'; // CUSTOMIZE: change to preferred production PDS 135export const signUpPDS = dev ? devPDS : prodPDS; 136 137export const REDIRECT_PATH = '/oauth/callback'; 138 139// redirect the user back to the page they were on before login 140export const REDIRECT_TO_LAST_PAGE_ON_LOGIN = true; 141 142export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query'; 143``` 144 145## Step 4: Modify existing files 146 147### `src/app.d.ts` 148 149Add these to the existing `App` namespace. Merge with any existing `Locals` or `Platform` fields — do not remove existing fields. 150 151```ts 152import type { OAuthSession } from '@atcute/oauth-node-client'; 153import type { Client } from '@atcute/client'; 154import type { Did } from '@atcute/lexicons'; 155``` 156 157Add to `App.Locals`: 158 159```ts 160session: OAuthSession | null; 161client: Client | null; 162did: Did | null; 163``` 164 165Add to `App.Platform`: 166 167```ts 168env: { 169 OAUTH_SESSIONS: KVNamespace; 170 OAUTH_STATES: KVNamespace; 171 CLIENT_ASSERTION_KEY: string; 172 COOKIE_SECRET: string; 173 OAUTH_PUBLIC_URL: string; 174 PROFILE_CACHE?: KVNamespace; 175}; 176``` 177 178Add at the bottom of the file (for lexicon type augmentation): 179 180```ts 181import type {} from '@atcute/atproto'; 182import type {} from '@atcute/bluesky'; 183``` 184 185### `src/hooks.server.ts` 186 187Add session restoration. If the file already has a `handle` export, wrap both in `sequence()` from `@sveltejs/kit`. 188 189```ts 190import type { Handle } from '@sveltejs/kit'; 191import { restoreSession } from '$lib/atproto/server/session'; 192 193const atprotoHandle: Handle = async ({ event, resolve }) => { 194 const { session, client, did } = await restoreSession( 195 event.cookies, event.platform?.env 196 ); 197 event.locals.session = session; 198 event.locals.client = client; 199 event.locals.did = did; 200 return resolve(event); 201}; 202``` 203 204If no existing hooks: `export const handle = atprotoHandle;` 205 206If existing hooks: `export const handle = sequence(existingHandle, atprotoHandle);` (import `sequence` from `@sveltejs/kit`) 207 208### `src/routes/+layout.server.ts` 209 210Add profile loading. Merge with any existing load function. 211 212```ts 213import type { LayoutServerLoad } from './$types'; 214import { loadProfile } from '$lib/atproto/server/profile'; 215 216export const load: LayoutServerLoad = async ({ locals, platform }) => { 217 if (!locals.did) return { did: null, profile: null }; 218 const profile = await loadProfile(locals.did, platform?.env?.PROFILE_CACHE); 219 return { did: locals.did, profile }; 220}; 221``` 222 223If a load function already exists, merge the profile data into its return value. 224 225### `src/routes/+layout.svelte` (foxui only) 226 227Only if the user chose `foxui`. Add the login modal to the existing layout. 228 229If signup is enabled (`ALLOW_SIGNUP = true`): 230 231```svelte 232<script lang="ts"> 233 import { AtprotoLoginModal } from '@foxui/social'; 234 import { login, signup } from '$lib/atproto'; 235</script> 236 237<!-- keep existing layout content, add this at the bottom: --> 238<AtprotoLoginModal 239 login={async (handle) => { 240 await login(handle); 241 return true; 242 }} 243 signup={async () => { 244 signup(); 245 return true; 246 }} 247/> 248``` 249 250If signup is disabled (`ALLOW_SIGNUP = false`), omit the `signup` prop: 251 252```svelte 253<script lang="ts"> 254 import { AtprotoLoginModal } from '@foxui/social'; 255 import { login } from '$lib/atproto'; 256</script> 257 258<AtprotoLoginModal 259 login={async (handle) => { 260 await login(handle); 261 return true; 262 }} 263/> 264``` 265 266To show the modal from anywhere, use `@foxui/social` state and `@foxui/core` components: 267 268```svelte 269<script lang="ts"> 270 import { Button } from '@foxui/core'; 271 import { atProtoLoginModalState } from '@foxui/social'; 272 import { user, logout } from '$lib/atproto'; 273</script> 274 275{#if user.isLoggedIn} 276 <p>Signed in as {user.profile?.handle ?? user.did}</p> 277 <Button onclick={() => logout()}>Sign Out</Button> 278{:else} 279 <Button onclick={() => atProtoLoginModalState.show()}>Sign In</Button> 280{/if} 281``` 282 283`@foxui/core` also exports `Avatar`, `Input`, and other UI primitives you can use. 284 285### `src/routes/user/+page.svelte` (basic only) 286 287Only if the user chose `basic`. Create this file: 288 289```svelte 290<script lang="ts"> 291 import { user, login, logout } from '$lib/atproto'; 292 293 let handle = $state(''); 294 let error = $state(''); 295 let loading = $state(false); 296 297 async function handleLogin() { 298 if (!handle.trim()) return; 299 loading = true; 300 error = ''; 301 try { 302 await login(handle); 303 } catch (e) { 304 error = e instanceof Error ? e.message : 'Login failed'; 305 loading = false; 306 } 307 } 308</script> 309 310<div class="mx-auto max-w-sm p-8"> 311 {#if user.isLoggedIn} 312 <p class="mb-4">Signed in as <strong>{user.profile?.handle ?? user.did}</strong></p> 313 <button 314 class="rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700" 315 onclick={() => logout()} 316 > 317 Sign Out 318 </button> 319 {:else} 320 <h1 class="mb-4 text-xl font-bold">Sign in</h1> 321 <form onsubmit={handleLogin} class="flex flex-col gap-3"> 322 <input 323 type="text" 324 bind:value={handle} 325 placeholder="handle.bsky.social" 326 class="rounded border px-3 py-2" 327 disabled={loading} 328 /> 329 {#if error} 330 <p class="text-sm text-red-600">{error}</p> 331 {/if} 332 <button 333 type="submit" 334 class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50" 335 disabled={loading || !handle.trim()} 336 > 337 {loading ? 'Signing in...' : 'Sign In'} 338 </button> 339 </form> 340 {/if} 341</div> 342``` 343 344If the project does not use Tailwind, replace the Tailwind classes with plain inline styles. 345 346### `svelte.config.js` 347 348Add `remoteFunctions: true` inside `kit.experimental`: 349 350```js 351kit: { 352 adapter: adapter(), 353 experimental: { 354 remoteFunctions: true 355 } 356} 357``` 358 359If `experimental` already exists, merge into it. Do not remove other experimental flags. 360 361### `vite.config.ts` 362 363Add the port import and dev server config for loopback OAuth: 364 365```ts 366import { DEV_PORT } from './src/lib/atproto/port'; 367``` 368 369```ts 370server: { 371 host: '127.0.0.1', 372 port: DEV_PORT 373} 374``` 375 376Add these inside `defineConfig()`. Do not remove existing plugins or config. 377 378### `wrangler.jsonc` 379 380Add or merge these fields: 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 397- Add `"nodejs_compat_v2"` to `compatibility_flags` (create the array if it doesn't exist) 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. 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: 400 401```jsonc 402{ "binding": "OAUTH_SESSIONS", "id": "TODO" }, 403{ "binding": "OAUTH_STATES", "id": "TODO" } 404``` 405 406Do not remove existing bindings or vars. 407 408### `tsconfig.json` 409 410Add `"@cloudflare/workers-types"` to `compilerOptions.types`. Create the `types` array if it doesn't exist. 411 412### `package.json` 413 414Add these to the `scripts` section: 415 416```json 417"env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts", 418"env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts", 419"env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts", 420"tunnel": "npx tsx src/lib/atproto/scripts/tunnel.ts" 421``` 422 423### `.gitignore` 424 425Ensure these lines are present: 426 427``` 428.env 429.env.* 430!.env.example 431``` 432 433## Step 5: Run setup and verify 434 4351. Run `pnpm env:setup-dev` to generate secrets in `.env` and assign a random dev port (5200–7200) in `port.ts` 4362. Run `pnpm dev` to start the dev server 4373. Verify it starts on `http://127.0.0.1:<DEV_PORT>` (the port from `port.ts`) 4384. Tell the user: 439 - Dev mode uses a loopback client (no keys needed) 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 444In 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 447pnpm tunnel 448``` 449 450This requires `cloudflared` to be installed. It: 4511. Spawns a Cloudflare Quick Tunnel pointing to `http://localhost:<DEV_PORT>` 4522. Sets `OAUTH_PUBLIC_URL` in `.env` to the tunnel URL 4533. Adds the tunnel hostname to `allowedHosts` in `vite.config.ts` 4544. Shows a persistent status bar with the tunnel URL 455 456After 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`. 457 458## Usage examples 459 460### Login / Logout 461 462```svelte 463<script lang="ts"> 464 import { user, login, logout } from '$lib/atproto'; 465</script> 466 467{#if user.isLoggedIn} 468 <p>Signed in as {user.did}</p> 469 <button onclick={() => logout()}>Sign Out</button> 470{:else} 471 <button onclick={() => login('user.bsky.social')}>Sign In</button> 472{/if} 473``` 474 475### Write operations 476 477```ts 478import { putRecord, deleteRecord, uploadBlob, createTID } from '$lib/atproto'; 479 480await putRecord({ 481 collection: 'your.collection.name', 482 rkey: createTID(), 483 record: { text: 'hello', createdAt: new Date().toISOString() } 484}); 485 486await deleteRecord({ collection: 'your.collection.name', rkey: 'some-key' }); 487 488const 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 494await 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 504import { getBlobURL, getCDNImageBlobUrl } from '$lib/atproto'; 505 506// Option 1: Direct PDS URL (works for any blob type) 507const url = await getBlobURL({ did: 'did:plc:...', blob: record.image }); 508 509// Option 2: Bluesky CDN URL (faster, cached, image-only, returns WebP thumbnails) 510const cdnUrl = getCDNImageBlobUrl({ did: 'did:plc:...', blob: record.image }); 511``` 512 513### Image compression helper 514 515The `image-helper.ts` module provides utilities for working with image blobs: 516 517```ts 518import { compressImage, checkAndUploadImage, getImageFromRecord } from '$lib/atproto/image-helper'; 519 520// Compress an image before uploading (default: max 900KB, max 2048px dimension, outputs WebP) 521const { blob: compressedBlob, aspectRatio } = await compressImage(file); 522const 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 526const record = { image: someFileOrUrl }; 527await 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) 530const displayUrl = getImageFromRecord(record, did, 'image'); 531``` 532 533### Read operations (no auth needed) 534 535```ts 536import { listRecords, getRecord, getDetailedProfile } from '$lib/atproto'; 537 538const records = await listRecords({ did: 'did:plc:...', collection: 'your.collection.name' }); 539const profile = await getDetailedProfile({ did: 'did:plc:...' }); 540```