···88- SvelteKit with `@sveltejs/adapter-cloudflare`
99- A `wrangler.jsonc` (or `wrangler.toml`) config
10101111-## Step 0: Ask the user
1111+## Step 0: Understand the project and propose an integration approach
1212+1313+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.
12141313-Before making any changes, ask the user these questions:
1515+### What to look for
1616+1717+- **User data**: localStorage, cookies, IndexedDB, database calls, API state — anything per-user that could live on a PDS instead.
1818+- **Content creation**: Does the app let users create, edit, or save things? (posts, notes, drawings, settings, lists, bookmarks, etc.)
1919+- **Existing routes**: How is the app structured? Would user-specific public pages make sense?
2020+2121+### Common integration patterns
2222+2323+Based on what you find, suggest the relevant patterns to the user:
2424+2525+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.
2626+2727+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.
2828+2929+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.).
3030+3131+4. **Content publishing** — If the app produces content (posts, articles, images, media), publish to the user's PDS under a custom collection namespace.
3232+3333+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.
3434+3535+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.
3636+3737+## Step 1: Ask configuration questions
3838+3939+Once the integration approach is agreed on, ask the user:
144015411. **UI**: Should I add a login UI?
1642 - **`foxui`** — Use `@foxui/social` login modal (polished, recommended)
1743 - **`basic`** — Simple login/logout page at `/user` route (uses Tailwind if available)
1844 - **`none`** — Backend only, no UI (you'll build your own)
19452020-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.
4646+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.)
214722483. **Blobs**: Does the app need to upload blobs (images, video)? If yes, what types? (e.g. `image/*`, `video/*`)
2349···30563157Use the answers to customize `settings.ts` (marked with `CUSTOMIZE` below) and choose which UI dependencies/files to create.
32583333-## Step 1: Install dependencies
5959+## Step 2: Install dependencies
34603561Always install:
3662···4571pnpm add @foxui/social @foxui/core
4672```
47734848-## Step 2: Create files
7474+## Step 3: Create files
49755076### Download all files
5177···5884 "src/lib/atproto/auth.svelte.ts"
5985 "src/lib/atproto/methods.ts"
6086 "src/lib/atproto/image-helper.ts"
8787+ "src/lib/atproto/port.ts"
6188 "src/lib/atproto/index.ts"
6289 "src/lib/atproto/server/signed-cookie.ts"
6390 "src/lib/atproto/server/kv-store.ts"
···6996 "src/lib/atproto/scripts/generate-key.ts"
7097 "src/lib/atproto/scripts/generate-secret.ts"
7198 "src/lib/atproto/scripts/setup-dev.ts"
9999+ "src/lib/atproto/scripts/tunnel.ts"
72100 "src/routes/(oauth)/oauth/callback/+server.ts"
73101 "src/routes/(oauth)/oauth/jwks.json/+server.ts"
74102 "src/routes/(oauth)/oauth-client-metadata.json/+server.ts"
···114142export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query';
115143```
116144117117-## Step 3: Modify existing files
145145+## Step 4: Modify existing files
118146119147### `src/app.d.ts`
120148···332360333361### `vite.config.ts`
334362335335-Add dev server config for loopback OAuth:
363363+Add the port import and dev server config for loopback OAuth:
364364+365365+```ts
366366+import { DEV_PORT } from './src/lib/atproto/port';
367367+```
336368337369```ts
338370server: {
339371 host: '127.0.0.1',
340340- port: 5183
372372+ port: DEV_PORT
341373}
342374```
343375344344-Add this inside `defineConfig()`. Do not remove existing plugins or config.
376376+Add these inside `defineConfig()`. Do not remove existing plugins or config.
345377346378### `wrangler.jsonc`
347379348380Add or merge these fields:
349381382382+- Ensure `"main"` is set to the SvelteKit Cloudflare worker entrypoint:
383383+384384+```jsonc
385385+"main": ".svelte-kit/cloudflare/_worker.js"
386386+```
387387+388388+- Ensure `"assets"` is configured:
389389+390390+```jsonc
391391+"assets": {
392392+ "binding": "ASSETS",
393393+ "directory": ".svelte-kit/cloudflare"
394394+}
395395+```
396396+350397- Add `"nodejs_compat_v2"` to `compatibility_flags` (create the array if it doesn't exist)
351398- 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.
352352-- Add KV namespace placeholders to `kv_namespaces`:
399399+- 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:
353400354401```jsonc
355402{ "binding": "OAUTH_SESSIONS", "id": "TODO" },
···369416```json
370417"env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts",
371418"env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts",
372372-"env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts"
419419+"env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts",
420420+"tunnel": "npx tsx src/lib/atproto/scripts/tunnel.ts"
373421```
374422375423### `.gitignore`
···382430!.env.example
383431```
384432385385-## Step 4: Run setup and verify
433433+## Step 5: Run setup and verify
386434387387-1. Run `pnpm env:setup-dev` to generate secrets in `.env`
435435+1. Run `pnpm env:setup-dev` to generate secrets in `.env` and assign a random dev port (5200–7200) in `port.ts`
3884362. Run `pnpm dev` to start the dev server
389389-3. Verify it starts on `http://127.0.0.1:5183`
437437+3. Verify it starts on `http://127.0.0.1:<DEV_PORT>` (the port from `port.ts`)
3904384. Tell the user:
391439 - Dev mode uses a loopback client (no keys needed)
392392- - 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`
440440+ - 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`
441441+442442+## Tunnel (for testing OAuth with a public URL)
443443+444444+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:
445445+446446+```sh
447447+pnpm tunnel
448448+```
449449+450450+This requires `cloudflared` to be installed. It:
451451+1. Spawns a Cloudflare Quick Tunnel pointing to `http://localhost:<DEV_PORT>`
452452+2. Sets `OAUTH_PUBLIC_URL` in `.env` to the tunnel URL
453453+3. Adds the tunnel hostname to `allowedHosts` in `vite.config.ts`
454454+4. Shows a persistent status bar with the tunnel URL
455455+456456+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`.
393457394458## Usage examples
395459···422486await deleteRecord({ collection: 'your.collection.name', rkey: 'some-key' });
423487424488const blob = await uploadBlob({ blob: file });
489489+// For images, returns: { $type: 'blob', ref: { $link: string }, mimeType: string, size: number, aspectRatio: { width, height } }
490490+// aspectRatio is auto-detected for image/* blobs, or you can pass it explicitly:
491491+// await uploadBlob({ blob: file, aspectRatio: { width: 2000, height: 1144 } })
492492+493493+// Store the blob reference in a record
494494+await putRecord({
495495+ collection: 'your.collection.name',
496496+ rkey: createTID(),
497497+ record: { image: blob, createdAt: new Date().toISOString() }
498498+});
499499+```
500500+501501+### Displaying blobs
502502+503503+```ts
504504+import { getBlobURL, getCDNImageBlobUrl } from '$lib/atproto';
505505+506506+// Option 1: Direct PDS URL (works for any blob type)
507507+const url = await getBlobURL({ did: 'did:plc:...', blob: record.image });
508508+509509+// Option 2: Bluesky CDN URL (faster, cached, image-only, returns WebP thumbnails)
510510+const cdnUrl = getCDNImageBlobUrl({ did: 'did:plc:...', blob: record.image });
511511+```
512512+513513+### Image compression helper
514514+515515+The `image-helper.ts` module provides utilities for working with image blobs:
516516+517517+```ts
518518+import { compressImage, checkAndUploadImage, getImageFromRecord } from '$lib/atproto/image-helper';
519519+520520+// Compress an image before uploading (default: max 900KB, max 2048px dimension, outputs WebP)
521521+const { blob: compressedBlob, aspectRatio } = await compressImage(file);
522522+const uploaded = await uploadBlob({ blob: compressedBlob });
523523+524524+// Or use checkAndUploadImage to handle compression + upload in one step
525525+// Works with File objects, string URLs (with an image proxy), or already-uploaded blob refs
526526+const record = { image: someFileOrUrl };
527527+await checkAndUploadImage(record, 'image', '/api/image-proxy?url=');
528528+529529+// Get a display URL from a record's blob field (handles blob refs, object URLs, and strings)
530530+const displayUrl = getImageFromRecord(record, did, 'image');
425531```
426532427533### Read operations (no auth needed)
···1313pnpm dev
1414```
15151616-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).
1616+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).
17171818-Open http://127.0.0.1:5183 and log in with any Bluesky handle.
1818+Open the URL shown in the terminal and log in with any Bluesky handle.
19192020## Configure your app
2121···35353636### 1. Create KV namespaces
37373838+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:
3939+3840```sh
3939-npx wrangler kv namespace create OAUTH_SESSIONS
4040-npx wrangler kv namespace create OAUTH_STATES
4141+npx wrangler kv namespace create <project-name>-OAUTH_SESSIONS
4242+npx wrangler kv namespace create <project-name>-OAUTH_STATES
4143```
42444345Each command outputs an ID. Put them in `wrangler.jsonc`:
···8284To test the full confidential client flow locally using [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/):
83858486```sh
8585-pnpm env:setup-dev # generates secrets in .env
8686-cloudflared tunnel --url http://localhost:5183 # start tunnel (note the URL it prints)
8787+pnpm env:setup-dev # generates secrets in .env + random port
8888+pnpm tunnel # start tunnel (uses port from port.ts)
8789```
88908991Add the tunnel URL to `.env`:
···103105| `pnpm check` | Run svelte-check |
104106| `pnpm env:generate-key` | Generate client assertion key |
105107| `pnpm env:generate-secret` | Generate cookie signing secret |
106106-| `pnpm env:setup-dev` | Generate both secrets and write to `.env` |
108108+| `pnpm env:setup-dev` | Generate secrets, write to `.env`, assign random dev port |
109109+| `pnpm tunnel` | Start a Cloudflare tunnel for testing OAuth with a public URL |
107110108111## Optional: Profile caching
109112
···1313pnpm dev
1414```
15151616-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.
1616+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`.)
17171818See [GETTING_STARTED.md](GETTING_STARTED.md) for production deployment, tunnel setup, and configuration.
1919···3737├── image-helper.ts # Image compression + upload helpers
3838├── index.ts # Public exports
3939├── methods.ts # AT Protocol helpers (read/write/resolve)
4040+├── port.ts # Dev server port (randomized per project)
4041├── settings.ts # Collections, scope, config constants
4142├── server/
4243│ ├── oauth.ts # OAuthClient factory (loopback vs confidential)
···4950└── scripts/
5051 ├── generate-key.ts
5152 ├── generate-secret.ts
5252- └── setup-dev.ts
5353+ ├── setup-dev.ts
5454+ └── tunnel.ts
53555456src/routes/(oauth)/
5557├── oauth/callback/+server.ts
···11+// Dev server port — generated by setup-dev, shared by vite, oauth, and tunnel.
22+// Each project gets a unique random port (5200–7200) so multiple projects can run simultaneously.
33+export const DEV_PORT = 5183;