[READ-ONLY] Mirror of https://github.com/flo-bit/svelte-cloudflare-statusphere.
statusphere.atmo.tools
atproto
cloudflare-workers
oauth
svelte
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```