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

commit

+345 -338
+2 -1
.claude/settings.local.json
··· 4 4 "Bash(npx tsc:*)", 5 5 "WebFetch(domain:raw.githubusercontent.com)", 6 6 "WebFetch(domain:github.com)", 7 - "mcp__plugin_svelte_svelte__svelte-autofixer" 7 + "mcp__plugin_svelte_svelte__svelte-autofixer", 8 + "mcp__plugin_svelte_svelte__get-documentation" 8 9 ] 9 10 } 10 11 }
+112 -105
README.md
··· 1 - # svelte atproto client oauth demo 1 + # svelte atproto cloudflare workers oauth demo 2 2 3 - try it here: http://flo-bit.dev/svelte-atproto-client-oauth/ 3 + A SvelteKit app that authenticates users via AT Protocol OAuth on Cloudflare Workers. Uses server-side OAuth with `@atcute/oauth-node-client`, Cloudflare KV for session/state storage, and SvelteKit remote functions for type-safe client-server communication. 4 4 5 - this is a scaffold for how to get client side oauth working with sveltekit and atproto 6 - using the [`atcute`](https://github.com/mary-ext/atcute) libraries. 5 + ## Prerequisites 7 6 8 - useful when you want people to login with atproto to your static sveltekit site. 7 + - [Node.js](https://nodejs.org/) (v18+) 8 + - [pnpm](https://pnpm.io/) 9 + - A [Cloudflare account](https://dash.cloudflare.com/sign-up) 10 + - [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) (`pnpm add -g wrangler`) 11 + - A domain pointed at Cloudflare (for production) 9 12 10 - ## how to install 13 + ## Local Development 11 14 12 - ### either clone this repo 15 + ### 1. Install dependencies 13 16 14 - 1. clone this repo 15 - 2. run `pnpm install` 16 - 3. run `pnpm run dev` 17 - 4. go to `http://127.0.0.1:5179` 18 - 5. if necessary change base in `svelte.config.js` 17 + ```sh 18 + pnpm install 19 + ``` 19 20 20 - ```js 21 - const config = { 22 - // ... 21 + ### 2. Run the dev server 23 22 24 - kit: { 25 - // ... 26 - 27 - paths: { 28 - base: process.env.NODE_ENV === 'development' ? '' : '/svelte-atproto-client-oauth' 29 - } 30 - } 31 - }; 23 + ```sh 24 + pnpm dev 32 25 ``` 33 26 34 - 6. change the SITE in `$lib/atproto/settings.ts` to your website 27 + In dev mode, the app uses a **loopback OAuth client** (public, no keys needed) with in-memory stores. It binds to `127.0.0.1:5183` — this is required for AT Protocol loopback OAuth. 28 + 29 + No Cloudflare setup is needed for local development. 35 30 36 - 7. setup the correct permissions (see below) 31 + ## Production Deployment 37 32 38 - ### or manually add to your own project 33 + ### 1. Create KV namespaces 39 34 40 - 1. copy the `src/lib/atproto` folder into your own project 41 - 2. also copy the `src/routes/oauth-client-metadata.json` folder into your project 42 - 3. initialize the client in your `src/routes/+layout.svelte` 35 + Create two KV namespaces for storing OAuth sessions and states: 43 36 44 - ```svelte 45 - <script> 46 - import { initClient } from '$lib/atproto'; 37 + ```sh 38 + npx wrangler kv namespace create OAUTH_SESSIONS 39 + npx wrangler kv namespace create OAUTH_STATES 40 + ``` 47 41 48 - let { children } = $props(); 42 + Each command outputs an ID. Update `wrangler.toml` with the actual IDs: 49 43 50 - onMount(() => { 51 - initClient(); 52 - }); 53 - </script> 44 + ```toml 45 + [[kv_namespaces]] 46 + binding = "OAUTH_SESSIONS" 47 + id = "<your-sessions-namespace-id>" 54 48 55 - {@render children()} 49 + [[kv_namespaces]] 50 + binding = "OAUTH_STATES" 51 + id = "<your-states-namespace-id>" 56 52 ``` 57 53 58 - 4. add server and port to your `vite.config.ts` 54 + ### 2. Generate a client assertion key 59 55 60 - ```js 61 - export default defineConfig({ 62 - server: { 63 - host: '127.0.0.1', 64 - port: 5179 65 - } 66 - }); 56 + AT Protocol OAuth requires a confidential client with a private key for production. Generate one: 57 + 58 + ```sh 59 + pnpm generate-key 67 60 ``` 68 61 69 - 5. install the dependencies 62 + This outputs a JSON key. Add it as a Cloudflare Workers secret: 70 63 71 - ```bash 72 - npm install @atcute/atproto @atcute/bluesky @atcute/identity-resolver @atcute/lexicons @atcute/oauth-browser-client @atcute/client 64 + ```sh 65 + npx wrangler secret put CLIENT_ASSERTION_KEY 73 66 ``` 74 67 75 - 6. (optionally) set your base in `svelte.config.js` (e.g. for deploying to github pages: `base: '/your-repo-name/'`) while keeping it as `''` in development. 68 + When prompted, paste the JSON key value. 76 69 77 - ```ts 78 - const config = { 79 - // ... 70 + ### 3. Configure your domain 80 71 81 - kit: { 82 - // ... 72 + Edit `src/lib/atproto/settings.ts` and set `SITE` to your production domain: 83 73 84 - paths: { 85 - base: process.env.NODE_ENV === 'development' ? '' : '/svelte-atproto-client-oauth' 86 - } 87 - } 88 - }; 74 + ```ts 75 + export const SITE = dev ? 'http://localhost:5183' : 'https://your-domain.com'; 89 76 ``` 90 77 91 - 6. change the SITE in `$lib/atproto/settings.ts` to your website 78 + ### 4. Serve the OAuth client metadata 92 79 93 - 7. setup the correct permissions (see below) 80 + AT Protocol OAuth requires a client metadata JSON to be publicly accessible at `https://your-domain.com/oauth-client-metadata.json`. This is referenced as the `client_id`. 94 81 95 - ## how to use 82 + Create `static/oauth-client-metadata.json` with your domain info, or serve it via a route. The metadata is constructed in `src/lib/atproto/metadata.ts` — the required fields are: 96 83 97 - ### set permissions you request on sign-in in `$lib/atproto/settings.ts` (see commented out examples for more info) 84 + ```json 85 + { 86 + "client_id": "https://your-domain.com/oauth-client-metadata.json", 87 + "redirect_uris": ["https://your-domain.com/oauth/callback"], 88 + "scope": "atproto repo:xyz.statusphere.status", 89 + "jwks_uri": "https://your-domain.com/oauth/jwks.json" 90 + } 91 + ``` 98 92 99 - - add collections to the collections array 100 - - rpcs for authenticated proxied requests 101 - - blobs for uploading blobs 93 + The scope is auto-generated from the `permissions` config in `src/lib/atproto/settings.ts`. 102 94 103 - ### change sign up pds 95 + ### 5. Configure permissions 104 96 105 - If you want to allow sign-up, change the `devPDS` and `prodPDS` variables in `$lib/atproto/settings.ts` to a pds of your choice 106 - 107 - ATTENTION: the current setting (pds.rip) is only for development, all accounts get deleted automatically after a week 108 - 109 - ### login flow 110 - 111 - Either use the `LoginModal` component to render a login modal or use the `user` object to handle the login flow yourself. 97 + Edit the `permissions` object in `src/lib/atproto/settings.ts` to control what your app can do: 112 98 113 99 ```ts 114 - // handlin login flow yourself 115 - import { user } from '$lib/atproto'; 100 + export const permissions = { 101 + // collections your app can create/delete/update records in 102 + collections: ['xyz.statusphere.status'], 116 103 117 - // methods: 118 - user.isInitializing; 119 - user.isLoggedIn; 120 - user.login(handle); 121 - user.signup(); 122 - user.logout(); 104 + // authenticated proxied RPC requests (e.g. to appview services) 105 + rpc: {}, 106 + 107 + // blob types your app can upload 108 + blobs: [] 109 + } as const; 123 110 ``` 124 111 125 - LoginModal is a component that renders a login modal, add it for a quick login flow (needs tailwind and tailwind/forms, copy the `src/app.css` content to your `app.css`). 112 + ### 6. Deploy 126 113 127 - ```svelte 128 - <script> 129 - import { LoginModal, loginModalState } from '$lib/atproto/ui'; 130 - </script> 114 + ```sh 115 + pnpm build 116 + npx wrangler deploy 117 + ``` 131 118 132 - <LoginModal /> 119 + Or deploy directly: 133 120 134 - <button onclick={() => loginModalState.show()}>Show Login Modal</button> 121 + ```sh 122 + npx wrangler deploy 135 123 ``` 136 124 137 - ### make requests 125 + ### 7. Set up a custom domain (recommended) 138 126 139 - Get the user's profile and make requests with the `user.client` object. 127 + In the Cloudflare dashboard, go to your Worker > Settings > Domains & Routes and add your custom domain. This is needed so the OAuth client metadata URL matches your `client_id`. 140 128 141 - ```ts 142 - import { user } from '$lib/atproto'; 129 + ## Project Structure 143 130 144 - // make requests with the user.client object 145 - // this example needs the getActorLikes rpc permission, set permissions 146 - const response = await user.client.get('app.bsky.feed.getActorLikes', { 147 - params: { 148 - actor: client.did, 149 - limit: 10 150 - } 151 - }); 131 + ``` 132 + src/ 133 + ├── lib/ 134 + │ ├── atproto/ 135 + │ │ ├── auth.svelte.ts # Client-side auth state (derived from server data) 136 + │ │ ├── metadata.ts # OAuth client metadata construction 137 + │ │ ├── methods.ts # AT Protocol helper methods 138 + │ │ ├── oauth.remote.ts # Remote functions: login, logout 139 + │ │ ├── repo.remote.ts # Remote functions: putRecord, deleteRecord, uploadBlob 140 + │ │ └── settings.ts # Site URL, permissions, constants 141 + │ └── server/ 142 + │ ├── oauth.ts # OAuthClient factory (dev vs prod) 143 + │ └── kv-store.ts # Cloudflare KV-backed Store implementation 144 + ├── routes/ 145 + │ ├── oauth/ 146 + │ │ ├── callback/ # OAuth callback handler (GET redirect) 147 + │ │ └── jwks.json/ # Public JWKS endpoint 148 + │ ├── +layout.server.ts # Loads user profile from session 149 + │ ├── +layout.svelte 150 + │ ├── +page.server.ts # Loads page-specific data 151 + │ └── +page.svelte 152 + └── hooks.server.ts # Session restoration from cookie 152 153 ``` 153 154 154 - ## todo 155 + ## How It Works 156 + 157 + - **Authentication**: Server-side OAuth flow via `@atcute/oauth-node-client`. Sessions are stored in Cloudflare KV and identified by a `did` cookie. 158 + - **Remote functions**: Write operations (putRecord, deleteRecord, uploadBlob) and auth actions (login, logout) use SvelteKit remote functions — type-safe server calls without manual API routes. 159 + - **Dev mode**: Uses AT Protocol's loopback client (no keys, in-memory storage). 160 + - **Prod mode**: Uses a confidential client with `private_key_jwt` assertion and KV-backed stores. 155 161 156 - - check if pds supports prompt=create 157 - - add lexicon stuff 162 + ## License 163 + 164 + MIT
+5 -2
package.json
··· 1 1 { 2 - "name": "svelte-atproto-client-oauth", 2 + "name": "svelte-atproto-oauth-cloudflare-workers", 3 3 "private": true, 4 4 "version": "0.1.0", 5 5 "type": "module", ··· 47 47 "vite": "^7.3.1", 48 48 "wrangler": "^4.0.0" 49 49 }, 50 - "license": "MIT" 50 + "license": "MIT", 51 + "dependencies": { 52 + "valibot": "^1.2.0" 53 + } 51 54 }
+16
pnpm-lock.yaml
··· 7 7 importers: 8 8 9 9 .: 10 + dependencies: 11 + valibot: 12 + specifier: ^1.2.0 13 + version: 1.2.0(typescript@5.9.3) 10 14 devDependencies: 11 15 '@atcute/atproto': 12 16 specifier: ^3.1.10 ··· 1912 1916 util-deprecate@1.0.2: 1913 1917 resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, tarball: https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz} 1914 1918 1919 + valibot@1.2.0: 1920 + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==, tarball: https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz} 1921 + peerDependencies: 1922 + typescript: '>=5' 1923 + peerDependenciesMeta: 1924 + typescript: 1925 + optional: true 1926 + 1915 1927 vite@7.3.1: 1916 1928 resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==, tarball: https://registry.npmjs.org/vite/-/vite-7.3.1.tgz} 1917 1929 engines: {node: ^20.19.0 || >=22.12.0} ··· 3599 3611 punycode: 2.3.1 3600 3612 3601 3613 util-deprecate@1.0.2: {} 3614 + 3615 + valibot@1.2.0(typescript@5.9.3): 3616 + optionalDependencies: 3617 + typescript: 5.9.3 3602 3618 3603 3619 vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): 3604 3620 dependencies:
+19 -55
src/lib/atproto/auth.svelte.ts
··· 1 1 import { AppBskyActorDefs } from '@atcute/bluesky'; 2 2 import type { ActorIdentifier, Did } from '@atcute/lexicons'; 3 - import { signUpPDS } from './settings'; 3 + import { page } from '$app/state'; 4 4 5 - export const user = $state({ 6 - profile: null as AppBskyActorDefs.ProfileViewDetailed | null | undefined, 7 - isInitializing: true, 8 - isLoggedIn: false, 9 - did: undefined as Did | undefined 10 - }); 11 - 12 - export function initFromServer(data: { did?: Did; profile?: AppBskyActorDefs.ProfileViewDetailed }) { 13 - if (data.did) { 14 - user.did = data.did as Did; 15 - user.profile = data.profile ?? null; 16 - user.isLoggedIn = true; 17 - 18 - // Cache profile in localStorage for UX 19 - if (data.profile && typeof localStorage !== 'undefined') { 20 - try { 21 - localStorage.setItem(`profile-${data.did}`, JSON.stringify(data.profile)); 22 - } catch {} 23 - } 5 + export const user = { 6 + get profile() { 7 + return page.data?.profile as AppBskyActorDefs.ProfileViewDetailed | undefined; 8 + }, 9 + get isLoggedIn() { 10 + return !!page.data?.did; 11 + }, 12 + get did() { 13 + return page.data?.did as Did | undefined; 24 14 } 25 - user.isInitializing = false; 26 - } 15 + }; 27 16 28 17 export async function login(handle: ActorIdentifier) { 29 18 if (handle.startsWith('did:')) { ··· 38 27 throw new Error('Please provide a valid handle or DID.'); 39 28 } 40 29 41 - const response = await fetch('/oauth/login', { 42 - method: 'POST', 43 - headers: { 'Content-Type': 'application/json' }, 44 - body: JSON.stringify({ handle }) 45 - }); 46 - 47 - if (!response.ok) { 48 - const err = (await response.json().catch(() => null)) as { message?: string } | null; 49 - throw new Error(err?.message || 'Login failed'); 50 - } 51 - 52 - const { url } = (await response.json()) as { url: string }; 30 + const { oauthLogin } = await import('./oauth.remote'); 31 + const { url } = await oauthLogin({ handle }); 53 32 window.location.assign(url); 54 33 55 34 // Wait for navigation (prevents UI flash) ··· 61 40 } 62 41 63 42 export async function signup() { 64 - const response = await fetch('/oauth/login', { 65 - method: 'POST', 66 - headers: { 'Content-Type': 'application/json' }, 67 - body: JSON.stringify({ signup: true }) 68 - }); 69 - 70 - if (!response.ok) { 71 - throw new Error('Signup failed'); 72 - } 73 - 74 - const { url } = (await response.json()) as { url: string }; 43 + const { oauthLogin } = await import('./oauth.remote'); 44 + const { url } = await oauthLogin({ signup: true }); 75 45 window.location.assign(url); 76 46 77 47 await new Promise((_resolve, reject) => { ··· 82 52 } 83 53 84 54 export async function logout() { 85 - const did = user.did; 86 - 87 55 try { 88 - await fetch('/oauth/logout', { method: 'POST' }); 56 + const { oauthLogout } = await import('./oauth.remote'); 57 + await oauthLogout(); 89 58 } catch (e) { 90 59 console.error('Error logging out:', e); 91 60 } 92 61 93 - if (did && typeof localStorage !== 'undefined') { 94 - localStorage.removeItem(`profile-${did}`); 95 - } 96 - 97 - user.profile = null; 98 - user.isLoggedIn = false; 99 - user.did = undefined; 62 + // Full reload to clear server session state 63 + window.location.href = '/'; 100 64 }
+1 -1
src/lib/atproto/index.ts
··· 1 - export { user, login, signup, logout, initFromServer } from './auth.svelte'; 1 + export { user, login, signup, logout } from './auth.svelte'; 2 2 export { metadata } from './metadata'; 3 3 4 4 export {
+10 -42
src/lib/atproto/methods.ts
··· 182 182 } 183 183 184 184 /** 185 - * Creates or updates a record via the server API route. 185 + * Creates or updates a record via remote function. 186 186 */ 187 187 export async function putRecord({ 188 188 collection, ··· 195 195 }) { 196 196 if (!user.did) throw new Error('Not logged in'); 197 197 198 - const response = await fetch('/api/repo/putRecord', { 199 - method: 'POST', 200 - headers: { 'Content-Type': 'application/json' }, 201 - body: JSON.stringify({ collection, rkey, record }) 202 - }); 203 - 204 - if (!response.ok) { 205 - throw new Error('Failed to put record'); 206 - } 207 - 208 - return { ok: true, data: await response.json() }; 198 + const { putRecord: putRecordRemote } = await import('./repo.remote'); 199 + const data = await putRecordRemote({ collection, rkey, record }); 200 + return { ok: true, data }; 209 201 } 210 202 211 203 /** 212 - * Deletes a record via the server API route. 204 + * Deletes a record via remote function. 213 205 */ 214 206 export async function deleteRecord({ 215 207 collection, ··· 220 212 }) { 221 213 if (!user.did) throw new Error('Not logged in'); 222 214 223 - const response = await fetch('/api/repo/deleteRecord', { 224 - method: 'POST', 225 - headers: { 'Content-Type': 'application/json' }, 226 - body: JSON.stringify({ collection, rkey }) 227 - }); 228 - 229 - if (!response.ok) { 230 - throw new Error('Failed to delete record'); 231 - } 232 - 233 - const data = (await response.json()) as { ok: boolean }; 215 + const { deleteRecord: deleteRecordRemote } = await import('./repo.remote'); 216 + const data = await deleteRecordRemote({ collection, rkey }); 234 217 return data.ok; 235 218 } 236 219 237 220 /** 238 - * Uploads a blob via the server API route. 221 + * Uploads a blob via remote function. 239 222 */ 240 223 export async function uploadBlob({ blob }: { blob: Blob }) { 241 224 if (!user.did) throw new Error("Can't upload blob: Not logged in"); 242 225 243 - const response = await fetch('/api/repo/uploadBlob', { 244 - method: 'POST', 245 - body: blob 246 - }); 247 - 248 - if (!response.ok) return; 249 - 250 - const blobInfo = (await response.json()) as { 251 - $type: 'blob'; 252 - ref: { 253 - $link: string; 254 - }; 255 - mimeType: string; 256 - size: number; 257 - }; 258 - 259 - return blobInfo; 226 + const { uploadBlob: uploadBlobRemote } = await import('./repo.remote'); 227 + return await uploadBlobRemote({ blob }); 260 228 } 261 229 262 230 /**
+47
src/lib/atproto/oauth.remote.ts
··· 1 + import * as v from 'valibot'; 2 + import { command, getRequestEvent } from '$app/server'; 3 + import { createOAuthClient } from '$lib/server/oauth'; 4 + import { scope } from '$lib/atproto/metadata'; 5 + import { signUpPDS } from '$lib/atproto/settings'; 6 + import type { ActorIdentifier, Did } from '@atcute/lexicons'; 7 + 8 + export const oauthLogin = command( 9 + v.object({ 10 + handle: v.optional(v.pipe(v.string(), v.minLength(3))), 11 + signup: v.optional(v.boolean()) 12 + }), 13 + async (input) => { 14 + const { platform } = getRequestEvent(); 15 + const oauth = createOAuthClient(platform?.env); 16 + 17 + const target = input.signup 18 + ? ({ type: 'pds', serviceUrl: signUpPDS } as const) 19 + : ({ type: 'account', identifier: input.handle as ActorIdentifier } as const); 20 + 21 + const { url } = await oauth.authorize({ 22 + target, 23 + scope, 24 + prompt: input.signup ? 'create' : undefined 25 + }); 26 + 27 + return { url: url.toString() }; 28 + } 29 + ); 30 + 31 + export const oauthLogout = command(async () => { 32 + const { cookies, platform } = getRequestEvent(); 33 + const did = cookies.get('did') as Did | undefined; 34 + 35 + if (did) { 36 + try { 37 + const oauth = createOAuthClient(platform?.env); 38 + await oauth.revoke(did); 39 + } catch (e) { 40 + console.error('Error revoking session:', e); 41 + } 42 + } 43 + 44 + cookies.delete('did', { path: '/' }); 45 + 46 + return { ok: true }; 47 + });
+76
src/lib/atproto/repo.remote.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import { command, getRequestEvent } from '$app/server'; 3 + import * as v from 'valibot'; 4 + 5 + const collectionSchema = v.pipe( 6 + v.string(), 7 + v.regex(/^[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*$/) 8 + ); 9 + 10 + export const putRecord = command( 11 + v.object({ 12 + collection: collectionSchema, 13 + rkey: v.optional(v.string()), 14 + record: v.record(v.string(), v.unknown()) 15 + }), 16 + async (input) => { 17 + const { locals } = getRequestEvent(); 18 + if (!locals.client || !locals.did) error(401, 'Not authenticated'); 19 + 20 + const response = await locals.client.post('com.atproto.repo.putRecord', { 21 + input: { 22 + collection: input.collection as `${string}.${string}.${string}`, 23 + repo: locals.did, 24 + rkey: input.rkey || 'self', 25 + record: input.record 26 + } 27 + }); 28 + 29 + return response.data; 30 + } 31 + ); 32 + 33 + export const deleteRecord = command( 34 + v.object({ 35 + collection: collectionSchema, 36 + rkey: v.optional(v.string()) 37 + }), 38 + async (input) => { 39 + const { locals } = getRequestEvent(); 40 + if (!locals.client || !locals.did) error(401, 'Not authenticated'); 41 + 42 + const response = await locals.client.post('com.atproto.repo.deleteRecord', { 43 + input: { 44 + collection: input.collection as `${string}.${string}.${string}`, 45 + repo: locals.did, 46 + rkey: input.rkey || 'self' 47 + } 48 + }); 49 + 50 + return { ok: response.ok }; 51 + } 52 + ); 53 + 54 + export const uploadBlob = command( 55 + v.object({ 56 + blob: v.instance(Blob) 57 + }), 58 + async (input) => { 59 + const { locals } = getRequestEvent(); 60 + if (!locals.client || !locals.did) error(401, 'Not authenticated'); 61 + 62 + const response = await locals.client.post('com.atproto.repo.uploadBlob', { 63 + params: { repo: locals.did }, 64 + input: input.blob 65 + }); 66 + 67 + if (!response?.ok) error(500, 'Upload failed'); 68 + 69 + return response.data.blob as { 70 + $type: 'blob'; 71 + ref: { $link: string }; 72 + mimeType: string; 73 + size: number; 74 + }; 75 + } 76 + );
+1 -6
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 3 4 - import { initFromServer } from '$lib/atproto'; 5 4 import LoginModal from '$lib/atproto/UI/LoginModal.svelte'; 6 5 7 - let { children, data } = $props(); 8 - 9 - $effect(() => { 10 - initFromServer({ did: data.did, profile: data.profile }); 11 - }); 6 + let { children } = $props(); 12 7 </script> 13 8 14 9 {@render children()}
+27
src/routes/+page.server.ts
··· 1 + import type { PageServerLoad } from './$types'; 2 + 3 + export const load: PageServerLoad = async ({ locals }) => { 4 + if (!locals.client || !locals.did) return { statuses: [] }; 5 + 6 + try { 7 + const response = await locals.client.get('com.atproto.repo.listRecords', { 8 + params: { 9 + repo: locals.did, 10 + collection: 'xyz.statusphere.status', 11 + limit: 10 12 + } 13 + }); 14 + 15 + if (!response.ok) return { statuses: [] }; 16 + 17 + return { 18 + statuses: response.data.records.map((r) => ({ 19 + rkey: r.uri.split('/').pop()!, 20 + status: (r.value as { status: string }).status, 21 + createdAt: (r.value as { createdAt: string }).createdAt 22 + })) 23 + }; 24 + } catch { 25 + return { statuses: [] }; 26 + } 27 + };
+22 -16
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { invalidateAll } from '$app/navigation'; 2 3 import { user, logout } from '$lib/atproto'; 3 4 import Avatar from '$lib/atproto/UI/Avatar.svelte'; 4 5 import Button from '$lib/atproto/UI/Button.svelte'; 5 6 import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 6 7 import { createTID } from '$lib/atproto/methods'; 8 + import { putRecord } from '$lib/atproto/repo.remote'; 7 9 8 10 let emojis = ['😅', '🫶', '🤗', '🙃', '😊', '🤔']; 11 + 12 + let { data } = $props(); 9 13 </script> 10 14 11 15 <div class="mx-auto my-4 max-w-3xl px-4 md:my-32"> ··· 17 21 class="dark:text-accent-500 mt-2 text-sm text-rose-600">source code</a 18 22 > 19 23 20 - {#if user.isInitializing} 21 - <div class="mt-8 text-sm">loading...</div> 22 - {/if} 23 - 24 - {#if !user.isInitializing && !user.isLoggedIn} 24 + {#if !user.isLoggedIn} 25 25 <div class="mt-8 text-sm">not logged in</div> 26 26 <Button class="mt-4" onclick={() => loginModalState.show()}>Login</Button> 27 27 {/if} ··· 37 37 <div class="my-4 text-sm"> 38 38 Statusphere test: 39 39 <div class="mt-2 flex flex-wrap gap-2"> 40 - {#each emojis as emoji} 40 + {#each emojis as emoji (emoji)} 41 41 <button 42 42 class="bg-base-100 dark:bg-base-950 cursor-pointer rounded-xl p-1 px-2" 43 43 onclick={async () => { 44 - await fetch('/api/repo/putRecord', { 45 - method: 'POST', 46 - body: JSON.stringify({ 47 - rkey: createTID(), 48 - collection: 'xyz.statusphere.status', 49 - record: { 50 - status: emoji, 51 - createdAt: new Date() 52 - } 53 - }) 44 + await putRecord({ 45 + rkey: createTID(), 46 + collection: 'xyz.statusphere.status', 47 + record: { 48 + status: emoji, 49 + createdAt: new Date() 50 + } 54 51 }); 52 + await invalidateAll(); 55 53 }}>{emoji}</button 56 54 > 57 55 {/each} 58 56 </div> 57 + {#if data.statuses.length > 0} 58 + <div class="mt-4 text-sm">Recent statuses:</div> 59 + <div class="mt-2 flex flex-wrap gap-1"> 60 + {#each data.statuses as status (status.rkey)} 61 + <span class="bg-base-100 dark:bg-base-950 rounded-xl p-1 px-2" title={new Date(status.createdAt).toLocaleString()}>{status.status}</span> 62 + {/each} 63 + </div> 64 + {/if} 59 65 </div> 60 66 61 67 <Button class="mt-4" onclick={() => logout()}>Sign Out</Button>
-21
src/routes/api/repo/deleteRecord/+server.ts
··· 1 - import { json, error } from '@sveltejs/kit'; 2 - import type { RequestHandler } from './$types'; 3 - 4 - export const POST: RequestHandler = async ({ request, locals }) => { 5 - if (!locals.client || !locals.did) throw error(401, 'Not authenticated'); 6 - 7 - const { collection, rkey } = (await request.json()) as { 8 - collection: `${string}.${string}.${string}`; 9 - rkey?: string; 10 - }; 11 - 12 - const response = await locals.client.post('com.atproto.repo.deleteRecord', { 13 - input: { 14 - collection, 15 - repo: locals.did, 16 - rkey: rkey || 'self' 17 - } 18 - }); 19 - 20 - return json({ ok: response.ok }); 21 - };
-23
src/routes/api/repo/putRecord/+server.ts
··· 1 - import { json, error } from '@sveltejs/kit'; 2 - import type { RequestHandler } from './$types'; 3 - 4 - export const POST: RequestHandler = async ({ request, locals }) => { 5 - if (!locals.client || !locals.did) throw error(401, 'Not authenticated'); 6 - 7 - const { collection, rkey, record } = (await request.json()) as { 8 - collection: `${string}.${string}.${string}`; 9 - rkey?: string; 10 - record: Record<string, unknown>; 11 - }; 12 - 13 - const response = await locals.client.post('com.atproto.repo.putRecord', { 14 - input: { 15 - collection, 16 - repo: locals.did, 17 - rkey: rkey || 'self', 18 - record 19 - } 20 - }); 21 - 22 - return json(response.data); 23 - };
-17
src/routes/api/repo/uploadBlob/+server.ts
··· 1 - import { json, error } from '@sveltejs/kit'; 2 - import type { RequestHandler } from './$types'; 3 - 4 - export const POST: RequestHandler = async ({ request, locals }) => { 5 - if (!locals.client || !locals.did) throw error(401, 'Not authenticated'); 6 - 7 - const blob = await request.blob(); 8 - 9 - const response = await locals.client.post('com.atproto.repo.uploadBlob', { 10 - params: { repo: locals.did }, 11 - input: blob 12 - }); 13 - 14 - if (!response?.ok) throw error(500, 'Upload failed'); 15 - 16 - return json(response.data.blob); 17 - };
-24
src/routes/oauth/login/+server.ts
··· 1 - import { json } from '@sveltejs/kit'; 2 - import { createOAuthClient } from '$lib/server/oauth'; 3 - import type { RequestHandler } from './$types'; 4 - import type { ActorIdentifier } from '@atcute/lexicons'; 5 - import { scope } from '$lib/atproto/metadata'; 6 - import { signUpPDS } from '$lib/atproto/settings'; 7 - 8 - export const POST: RequestHandler = async ({ request, platform }) => { 9 - const oauth = createOAuthClient(platform?.env); 10 - const body = await request.json(); 11 - const { handle, signup } = body as { handle?: string; signup?: boolean }; 12 - 13 - const target = signup 14 - ? ({ type: 'pds', serviceUrl: signUpPDS } as const) 15 - : ({ type: 'account', identifier: handle as ActorIdentifier } as const); 16 - 17 - const { url } = await oauth.authorize({ 18 - target, 19 - scope, 20 - prompt: signup ? 'create' : undefined 21 - }); 22 - 23 - return json({ url: url.toString() }); 24 - };
-21
src/routes/oauth/logout/+server.ts
··· 1 - import { json } from '@sveltejs/kit'; 2 - import { createOAuthClient } from '$lib/server/oauth'; 3 - import type { RequestHandler } from './$types'; 4 - import type { Did } from '@atcute/lexicons'; 5 - 6 - export const POST: RequestHandler = async ({ cookies, platform }) => { 7 - const did = cookies.get('did') as Did | undefined; 8 - 9 - if (did) { 10 - try { 11 - const oauth = createOAuthClient(platform?.env); 12 - await oauth.revoke(did); 13 - } catch (e) { 14 - console.error('Error revoking session:', e); 15 - } 16 - } 17 - 18 - cookies.delete('did', { path: '/' }); 19 - 20 - return json({ ok: true }); 21 - };
+4 -1
svelte.config.js
··· 6 6 preprocess: vitePreprocess(), 7 7 8 8 kit: { 9 - adapter: adapter() 9 + adapter: adapter(), 10 + experimental: { 11 + remoteFunctions: true 12 + } 10 13 } 11 14 }; 12 15
+3 -3
wrangler.toml
··· 1 - name = "svelte-atproto-oauth" 1 + name = "svelte-atproto-oauth-cloudflare-workers" 2 2 compatibility_date = "2024-12-01" 3 3 compatibility_flags = ["nodejs_compat"] 4 4 5 5 [[kv_namespaces]] 6 6 binding = "OAUTH_SESSIONS" 7 - id = "REPLACE_WITH_KV_NAMESPACE_ID" 7 + id = "c44fc97274b84f5a97a8399b1977ad0d" 8 8 9 9 [[kv_namespaces]] 10 10 binding = "OAUTH_STATES" 11 - id = "REPLACE_WITH_KV_NAMESPACE_ID" 11 + id = "115d8089d9ca4592a3a5e4a7d9146610"