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

big commit

+2487 -784
+3 -1
.claude/settings.local.json
··· 5 5 "WebFetch(domain:raw.githubusercontent.com)", 6 6 "WebFetch(domain:github.com)", 7 7 "mcp__plugin_svelte_svelte__svelte-autofixer", 8 - "mcp__plugin_svelte_svelte__get-documentation" 8 + "mcp__plugin_svelte_svelte__get-documentation", 9 + "Bash(pnpm check:*)", 10 + "Bash(pnpm env:generate-secret:*)" 9 11 ] 10 12 } 11 13 }
-1
.dev.vars.example
··· 1 - CLIENT_ASSERTION_KEY={"kty":"EC","crv":"P-256","x":"...","y":"...","d":"...","kid":"main-key","alg":"ES256"}
+6
.env.example
··· 1 + # Generate both with: pnpm env:setup-dev 2 + CLIENT_ASSERTION_KEY= 3 + COOKIE_SECRET= 4 + 5 + # Set to your tunnel URL to use a confidential client in dev 6 + # OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com
-2
.gitignore
··· 15 15 # Env 16 16 .env 17 17 .env.* 18 - .dev.vars 19 18 !.env.example 20 19 !.env.test 21 - !.dev.vars.example 22 20 23 21 # Vite 24 22 vite.config.js.timestamp-*
+1373
AGENT_SETUP.md
··· 1 + # Add AT Protocol OAuth to SvelteKit + Cloudflare Workers 2 + 3 + You 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 + 7 + The project must already use: 8 + - SvelteKit with `@sveltejs/adapter-cloudflare` 9 + - A `wrangler.jsonc` (or `wrangler.toml`) config 10 + 11 + ## Step 0: Ask the user 12 + 13 + Before making any changes, ask the user these questions: 14 + 15 + 1. **UI**: Should I add a login UI? 16 + - **`foxui`** — Use `@foxui/social` login modal (polished, recommended) 17 + - **`basic`** — Simple login/logout page at `/user` route (uses Tailwind if available) 18 + - **`none`** — Backend only, no UI (you'll build your own) 19 + 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. 21 + 22 + 3. **Blobs**: Does the app need to upload blobs (images, video)? If yes, what types? (e.g. `image/*`, `video/*`) 23 + 24 + Use the answers to customize `settings.ts` (marked with `CUSTOMIZE` below) and choose which UI dependencies/files to create. 25 + 26 + ## Step 1: Install dependencies 27 + 28 + Always install: 29 + 30 + ```sh 31 + pnpm add valibot 32 + pnpm add -D @atcute/oauth-node-client @atcute/identity-resolver @atcute/lexicons @atcute/client @atcute/tid @cloudflare/workers-types tsx @atcute/atproto @atcute/bluesky 33 + ``` 34 + 35 + If UI choice is `foxui`: 36 + 37 + ```sh 38 + pnpm add @foxui/social @foxui/core 39 + ``` 40 + 41 + ## Step 2: Create files 42 + 43 + Create all of the following files. These go into `src/lib/atproto/` and `src/routes/(oauth)/`. 44 + 45 + ### `src/lib/atproto/settings.ts` 46 + 47 + Fill in `collections` and `blobs` from the user's answers. If no collections were specified, use an empty array. 48 + 49 + ```ts 50 + import { dev } from '$app/environment'; 51 + 52 + type Permissions = { 53 + collections: readonly string[]; 54 + rpc: Record<string, string | string[]>; 55 + blobs: readonly string[]; 56 + }; 57 + 58 + export const permissions = { 59 + // CUSTOMIZE: add the user's collections 60 + collections: [], 61 + 62 + // CUSTOMIZE: add any authenticated RPC requests needed 63 + rpc: {}, 64 + 65 + // CUSTOMIZE: add blob types if the user needs uploads (e.g. ['image/*']) 66 + blobs: [] 67 + } as const satisfies Permissions; 68 + 69 + type ExtractCollectionBase<T extends string> = T extends `${infer Base}?${string}` ? Base : T; 70 + 71 + export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>; 72 + 73 + // PDS to use for signup (change to preferred PDS) 74 + const devPDS = 'https://bsky.social/'; 75 + const prodPDS = 'https://bsky.social/'; 76 + export const signUpPDS = dev ? devPDS : prodPDS; 77 + 78 + export const REDIRECT_PATH = '/oauth/callback'; 79 + 80 + export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query'; 81 + ``` 82 + 83 + ### `src/lib/atproto/metadata.ts` 84 + 85 + ```ts 86 + import { permissions } from './settings'; 87 + 88 + function constructScope() { 89 + const parts: string[] = ['atproto']; 90 + 91 + for (const collection of permissions.collections) { 92 + parts.push('repo:' + collection); 93 + } 94 + 95 + for (const [key, value] of Object.entries(permissions.rpc ?? {})) { 96 + const lxms = Array.isArray(value) ? value : [value]; 97 + for (const lxm of lxms) { 98 + parts.push('rpc?lxm=' + lxm + '&aud=' + key); 99 + } 100 + } 101 + 102 + if (permissions.blobs.length > 0) { 103 + parts.push('blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&')); 104 + } 105 + 106 + return parts.join(' '); 107 + } 108 + 109 + export const scope = constructScope(); 110 + ``` 111 + 112 + ### `src/lib/atproto/auth.svelte.ts` 113 + 114 + ```ts 115 + import { AppBskyActorDefs } from '@atcute/bluesky'; 116 + import type { ActorIdentifier, Did } from '@atcute/lexicons'; 117 + import { page } from '$app/state'; 118 + 119 + export const user = { 120 + get profile() { 121 + return (page.data?.profile as AppBskyActorDefs.ProfileViewDetailed | null) ?? null; 122 + }, 123 + get isLoggedIn() { 124 + return !!page.data?.did; 125 + }, 126 + get did() { 127 + return (page.data?.did as Did | null) ?? null; 128 + } 129 + }; 130 + 131 + export async function login(handle: string) { 132 + if (handle.startsWith('did:')) { 133 + if (handle.length < 6) throw new Error('DID must be at least 6 characters'); 134 + } else if (handle.includes('.') && handle.length > 3) { 135 + handle = (handle.startsWith('@') ? handle.slice(1) : handle) as ActorIdentifier; 136 + if (handle.length < 4) throw new Error('Handle must be at least 4 characters'); 137 + } else if (handle.length > 3) { 138 + handle = ((handle.startsWith('@') ? handle.slice(1) : handle) + 139 + '.bsky.social') as ActorIdentifier; 140 + } else { 141 + throw new Error('Please provide a valid handle or DID.'); 142 + } 143 + 144 + const { oauthLogin } = await import('./server/oauth.remote'); 145 + const { url } = await oauthLogin({ handle }); 146 + window.location.assign(url); 147 + 148 + await new Promise((_resolve, reject) => { 149 + window.addEventListener('pageshow', () => reject(new Error('user aborted the login request')), { 150 + once: true 151 + }); 152 + }); 153 + } 154 + 155 + export async function signup() { 156 + const { oauthLogin } = await import('./server/oauth.remote'); 157 + const { url } = await oauthLogin({ signup: true }); 158 + window.location.assign(url); 159 + 160 + await new Promise((_resolve, reject) => { 161 + window.addEventListener('pageshow', () => reject(new Error('user aborted the signup request')), { 162 + once: true 163 + }); 164 + }); 165 + } 166 + 167 + export async function logout() { 168 + try { 169 + const { oauthLogout } = await import('./server/oauth.remote'); 170 + await oauthLogout(); 171 + } catch (e) { 172 + console.error('Error logging out:', e); 173 + } 174 + 175 + window.location.href = '/'; 176 + } 177 + ``` 178 + 179 + ### `src/lib/atproto/methods.ts` 180 + 181 + ```ts 182 + import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons'; 183 + import { user } from './auth.svelte'; 184 + import { DOH_RESOLVER, type AllowedCollection } from './settings'; 185 + import { 186 + CompositeDidDocumentResolver, 187 + CompositeHandleResolver, 188 + DohJsonHandleResolver, 189 + PlcDidDocumentResolver, 190 + WebDidDocumentResolver, 191 + WellKnownHandleResolver 192 + } from '@atcute/identity-resolver'; 193 + import { Client, simpleFetchHandler } from '@atcute/client'; 194 + import { type AppBskyActorDefs } from '@atcute/bluesky'; 195 + 196 + export type Collection = `${string}.${string}.${string}`; 197 + import * as TID from '@atcute/tid'; 198 + 199 + export function parseUri(uri: string) { 200 + const parts = parseResourceUri(uri); 201 + if (!parts.ok) return; 202 + return parts.value; 203 + } 204 + 205 + export async function resolveHandle({ handle }: { handle: Handle }) { 206 + const handleResolver = new CompositeHandleResolver({ 207 + methods: { 208 + dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }), 209 + http: new WellKnownHandleResolver() 210 + } 211 + }); 212 + 213 + const data = await handleResolver.resolve(handle); 214 + return data; 215 + } 216 + 217 + const didResolver = new CompositeDidDocumentResolver({ 218 + methods: { 219 + plc: new PlcDidDocumentResolver(), 220 + web: new WebDidDocumentResolver() 221 + } 222 + }); 223 + 224 + export async function getPDS(did: Did) { 225 + const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>); 226 + if (!doc.service) throw new Error('No PDS found'); 227 + for (const service of doc.service) { 228 + if (service.id === '#atproto_pds') { 229 + return service.serviceEndpoint.toString(); 230 + } 231 + } 232 + } 233 + 234 + export async function getDetailedProfile(data?: { did?: Did; client?: Client }) { 235 + data ??= {}; 236 + data.did ??= user.did ?? undefined; 237 + 238 + if (!data.did) throw new Error('Error getting detailed profile: no did'); 239 + 240 + data.client ??= new Client({ 241 + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 242 + }); 243 + 244 + const response = await data.client.get('app.bsky.actor.getProfile', { 245 + params: { actor: data.did } 246 + }); 247 + 248 + if (!response.ok) return; 249 + 250 + return response.data; 251 + } 252 + 253 + export async function getClient({ did }: { did: Did }) { 254 + const pds = await getPDS(did); 255 + if (!pds) throw new Error('PDS not found'); 256 + 257 + const client = new Client({ 258 + handler: simpleFetchHandler({ service: pds }) 259 + }); 260 + 261 + return client; 262 + } 263 + 264 + export async function listRecords({ 265 + did, 266 + collection, 267 + cursor, 268 + limit = 100, 269 + client 270 + }: { 271 + did?: Did; 272 + collection: `${string}.${string}.${string}`; 273 + cursor?: string; 274 + limit?: number; 275 + client?: Client; 276 + }) { 277 + did ??= user.did ?? undefined; 278 + if (!collection) { 279 + throw new Error('Missing parameters for listRecords'); 280 + } 281 + if (!did) { 282 + throw new Error('Missing did for listRecords'); 283 + } 284 + 285 + client ??= await getClient({ did }); 286 + 287 + const allRecords = []; 288 + 289 + let currentCursor = cursor; 290 + do { 291 + const response = await client.get('com.atproto.repo.listRecords', { 292 + params: { 293 + repo: did, 294 + collection, 295 + limit: !limit || limit > 100 ? 100 : limit, 296 + cursor: currentCursor 297 + } 298 + }); 299 + 300 + if (!response.ok) { 301 + return allRecords; 302 + } 303 + 304 + allRecords.push(...response.data.records); 305 + currentCursor = response.data.cursor; 306 + } while (currentCursor && (!limit || allRecords.length < limit)); 307 + 308 + return allRecords; 309 + } 310 + 311 + export async function getRecord({ 312 + did, 313 + collection, 314 + rkey = 'self', 315 + client 316 + }: { 317 + did?: Did; 318 + collection: Collection; 319 + rkey?: string; 320 + client?: Client; 321 + }) { 322 + did ??= user.did ?? undefined; 323 + 324 + if (!collection) { 325 + throw new Error('Missing parameters for getRecord'); 326 + } 327 + if (!did) { 328 + throw new Error('Missing did for getRecord'); 329 + } 330 + 331 + client ??= await getClient({ did }); 332 + 333 + const record = await client.get('com.atproto.repo.getRecord', { 334 + params: { 335 + repo: did, 336 + collection, 337 + rkey 338 + } 339 + }); 340 + 341 + return JSON.parse(JSON.stringify(record.data)); 342 + } 343 + 344 + export async function putRecord({ 345 + collection, 346 + rkey = 'self', 347 + record 348 + }: { 349 + collection: AllowedCollection; 350 + rkey?: string; 351 + record: Record<string, unknown>; 352 + }) { 353 + if (!user.did) throw new Error('Not logged in'); 354 + 355 + const { putRecord: putRecordRemote } = await import('./server/repo.remote'); 356 + const data = await putRecordRemote({ collection, rkey, record }); 357 + return { ok: true, data }; 358 + } 359 + 360 + export async function deleteRecord({ 361 + collection, 362 + rkey = 'self' 363 + }: { 364 + collection: AllowedCollection; 365 + rkey: string; 366 + }) { 367 + if (!user.did) throw new Error('Not logged in'); 368 + 369 + const { deleteRecord: deleteRecordRemote } = await import('./server/repo.remote'); 370 + const data = await deleteRecordRemote({ collection, rkey }); 371 + return data.ok; 372 + } 373 + 374 + export async function uploadBlob({ blob }: { blob: Blob }) { 375 + if (!user.did) throw new Error("Can't upload blob: Not logged in"); 376 + 377 + const { uploadBlob: uploadBlobRemote } = await import('./server/repo.remote'); 378 + return await uploadBlobRemote({ blob }); 379 + } 380 + 381 + export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { 382 + did ??= user.did ?? undefined; 383 + if (!did) { 384 + throw new Error('Error describeRepo: No did'); 385 + } 386 + client ??= await getClient({ did }); 387 + 388 + const repo = await client.get('com.atproto.repo.describeRepo', { 389 + params: { 390 + repo: did 391 + } 392 + }); 393 + if (!repo.ok) return; 394 + 395 + return repo.data; 396 + } 397 + 398 + export async function getBlobURL({ 399 + did, 400 + blob 401 + }: { 402 + did: Did; 403 + blob: { 404 + $type: 'blob'; 405 + ref: { 406 + $link: string; 407 + }; 408 + }; 409 + }) { 410 + const pds = await getPDS(did); 411 + return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; 412 + } 413 + 414 + export function getCDNImageBlobUrl({ 415 + did, 416 + blob 417 + }: { 418 + did?: string; 419 + blob: { 420 + $type: 'blob'; 421 + ref: { 422 + $link: string; 423 + }; 424 + }; 425 + }) { 426 + did ??= user.did ?? undefined; 427 + 428 + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; 429 + } 430 + 431 + export async function searchActorsTypeahead( 432 + q: string, 433 + limit: number = 10, 434 + host?: string 435 + ): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> { 436 + host ??= 'https://public.api.bsky.app'; 437 + 438 + const client = new Client({ 439 + handler: simpleFetchHandler({ service: host }) 440 + }); 441 + 442 + const response = await client.get('app.bsky.actor.searchActorsTypeahead', { 443 + params: { 444 + q, 445 + limit 446 + } 447 + }); 448 + 449 + if (!response.ok) return { actors: [], q }; 450 + 451 + return { actors: response.data.actors, q }; 452 + } 453 + 454 + export function createTID() { 455 + return TID.now(); 456 + } 457 + ``` 458 + 459 + ### `src/lib/atproto/index.ts` 460 + 461 + ```ts 462 + export { user, login, signup, logout } from './auth.svelte'; 463 + 464 + export { 465 + parseUri, 466 + resolveHandle, 467 + getPDS, 468 + getDetailedProfile, 469 + getClient, 470 + listRecords, 471 + getRecord, 472 + putRecord, 473 + deleteRecord, 474 + uploadBlob, 475 + describeRepo, 476 + getBlobURL, 477 + getCDNImageBlobUrl, 478 + searchActorsTypeahead, 479 + createTID 480 + } from './methods'; 481 + ``` 482 + 483 + ### `src/lib/atproto/server/signed-cookie.ts` 484 + 485 + ```ts 486 + import { createHmac, timingSafeEqual } from 'node:crypto'; 487 + 488 + import type { Cookies } from '@sveltejs/kit'; 489 + 490 + import { env } from '$env/dynamic/private'; 491 + import { dev } from '$app/environment'; 492 + 493 + const SEPARATOR = '.'; 494 + 495 + function getSecret(): string { 496 + const secret = env.COOKIE_SECRET; 497 + if (secret) return secret; 498 + if (dev) return 'dev-cookie-secret-not-for-production'; 499 + throw new Error('COOKIE_SECRET is not set'); 500 + } 501 + 502 + function toBase64Url(bytes: Uint8Array): string { 503 + let binary = ''; 504 + for (const byte of bytes) binary += String.fromCharCode(byte); 505 + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 506 + } 507 + 508 + function fromBase64Url(str: string): Uint8Array { 509 + const padded = str + '='.repeat((4 - (str.length % 4)) % 4); 510 + const base64 = padded.replace(/-/g, '+').replace(/_/g, '/'); 511 + const binary = atob(base64); 512 + const bytes = new Uint8Array(binary.length); 513 + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); 514 + return bytes; 515 + } 516 + 517 + function hmacSha256(data: string): Uint8Array { 518 + return createHmac('sha256', getSecret()).update(data).digest(); 519 + } 520 + 521 + export function getSignedCookie(cookies: Cookies, name: string): string | null { 522 + const signed = cookies.get(name); 523 + if (!signed) return null; 524 + 525 + const idx = signed.lastIndexOf(SEPARATOR); 526 + if (idx === -1) return null; 527 + 528 + const value = signed.slice(0, idx); 529 + const sig = signed.slice(idx + 1); 530 + 531 + let expected: Uint8Array; 532 + let got: Uint8Array; 533 + try { 534 + expected = hmacSha256(value); 535 + got = fromBase64Url(sig); 536 + } catch { 537 + return null; 538 + } 539 + 540 + if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null; 541 + 542 + return value; 543 + } 544 + 545 + export function setSignedCookie( 546 + cookies: Cookies, 547 + name: string, 548 + value: string, 549 + options: Parameters<Cookies['set']>[2] 550 + ): void { 551 + const sig = toBase64Url(hmacSha256(value)); 552 + const signed = `${value}${SEPARATOR}${sig}`; 553 + cookies.set(name, signed, options); 554 + } 555 + ``` 556 + 557 + ### `src/lib/atproto/server/kv-store.ts` 558 + 559 + ```ts 560 + import type { Store } from '@atcute/oauth-node-client'; 561 + 562 + export class KVStore<K extends string, V> implements Store<K, V> { 563 + private kv: KVNamespace; 564 + private expirationTtl?: number; 565 + 566 + constructor(kv: KVNamespace, options?: { expirationTtl?: number }) { 567 + this.kv = kv; 568 + this.expirationTtl = options?.expirationTtl; 569 + } 570 + 571 + async get(key: K): Promise<V | undefined> { 572 + const value = await this.kv.get(key, 'text'); 573 + if (value === null) return undefined; 574 + return JSON.parse(value) as V; 575 + } 576 + 577 + async set(key: K, value: V): Promise<void> { 578 + await this.kv.put(key, JSON.stringify(value), { 579 + expirationTtl: this.expirationTtl 580 + }); 581 + } 582 + 583 + async delete(key: K): Promise<void> { 584 + await this.kv.delete(key); 585 + } 586 + 587 + async clear(): Promise<void> { 588 + let cursor: string | undefined; 589 + do { 590 + const result = await this.kv.list({ cursor }); 591 + for (const key of result.keys) { 592 + await this.kv.delete(key.name); 593 + } 594 + cursor = result.list_complete ? undefined : result.cursor; 595 + } while (cursor); 596 + } 597 + } 598 + ``` 599 + 600 + ### `src/lib/atproto/server/oauth.ts` 601 + 602 + ```ts 603 + import { 604 + OAuthClient, 605 + MemoryStore, 606 + type ClientAssertionPrivateJwk, 607 + type OAuthClientStores, 608 + type OAuthSession, 609 + type StoredSession, 610 + type StoredState 611 + } from '@atcute/oauth-node-client'; 612 + import type { Did } from '@atcute/lexicons'; 613 + import { 614 + CompositeDidDocumentResolver, 615 + CompositeHandleResolver, 616 + DohJsonHandleResolver, 617 + LocalActorResolver, 618 + PlcDidDocumentResolver, 619 + WebDidDocumentResolver, 620 + WellKnownHandleResolver 621 + } from '@atcute/identity-resolver'; 622 + import { KVStore } from './kv-store'; 623 + import { DOH_RESOLVER, REDIRECT_PATH } from '../settings'; 624 + import { scope } from '../metadata'; 625 + import { dev } from '$app/environment'; 626 + 627 + function createActorResolver() { 628 + return new LocalActorResolver({ 629 + handleResolver: new CompositeHandleResolver({ 630 + methods: { 631 + dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }), 632 + http: new WellKnownHandleResolver() 633 + } 634 + }), 635 + didDocumentResolver: new CompositeDidDocumentResolver({ 636 + methods: { 637 + plc: new PlcDidDocumentResolver(), 638 + web: new WebDidDocumentResolver() 639 + } 640 + }) 641 + }); 642 + } 643 + 644 + function createStores(env?: App.Platform['env']): OAuthClientStores { 645 + if (env?.OAUTH_SESSIONS && env?.OAUTH_STATES) { 646 + return { 647 + sessions: new KVStore<Did, StoredSession>(env.OAUTH_SESSIONS), 648 + states: new KVStore<string, StoredState>(env.OAUTH_STATES, { expirationTtl: 600 }) 649 + }; 650 + } 651 + return { 652 + sessions: new MemoryStore<Did, StoredSession>(), 653 + states: new MemoryStore<string, StoredState>({ ttl: 600_000 }) 654 + }; 655 + } 656 + 657 + export function createOAuthClient(env?: App.Platform['env']): OAuthClient { 658 + const actorResolver = createActorResolver(); 659 + const stores = createStores(env); 660 + 661 + if (dev && !env?.OAUTH_PUBLIC_URL) { 662 + return new OAuthClient({ 663 + metadata: { 664 + redirect_uris: [`http://127.0.0.1:5183${REDIRECT_PATH}`], 665 + scope 666 + }, 667 + actorResolver, 668 + stores 669 + }); 670 + } 671 + 672 + if (!env?.OAUTH_PUBLIC_URL) { 673 + throw new Error('OAUTH_PUBLIC_URL is not set'); 674 + } 675 + if (!env.CLIENT_ASSERTION_KEY) { 676 + throw new Error('CLIENT_ASSERTION_KEY secret is not set. Run: pnpm env:generate-key'); 677 + } 678 + const site = env.OAUTH_PUBLIC_URL; 679 + const key: ClientAssertionPrivateJwk = JSON.parse(env.CLIENT_ASSERTION_KEY); 680 + 681 + return new OAuthClient({ 682 + metadata: { 683 + client_id: site + '/oauth-client-metadata.json', 684 + redirect_uris: [site + REDIRECT_PATH], 685 + scope, 686 + jwks_uri: site + '/oauth/jwks.json' 687 + }, 688 + keyset: [key], 689 + actorResolver, 690 + stores 691 + }); 692 + } 693 + 694 + export type { OAuthSession }; 695 + ``` 696 + 697 + ### `src/lib/atproto/server/oauth.remote.ts` 698 + 699 + ```ts 700 + import * as v from 'valibot'; 701 + import { error } from '@sveltejs/kit'; 702 + import { command, getRequestEvent } from '$app/server'; 703 + import { createOAuthClient } from './oauth'; 704 + import { getSignedCookie } from './signed-cookie'; 705 + import { scope } from '../metadata'; 706 + import { signUpPDS } from '../settings'; 707 + import type { ActorIdentifier, Did } from '@atcute/lexicons'; 708 + 709 + export const oauthLogin = command( 710 + v.object({ 711 + handle: v.optional(v.pipe(v.string(), v.minLength(3))), 712 + signup: v.optional(v.boolean()) 713 + }), 714 + async (input) => { 715 + const { platform } = getRequestEvent(); 716 + 717 + try { 718 + const oauth = createOAuthClient(platform?.env); 719 + 720 + const target = input.signup 721 + ? ({ type: 'pds', serviceUrl: signUpPDS } as const) 722 + : ({ type: 'account', identifier: input.handle as ActorIdentifier } as const); 723 + 724 + const { url } = await oauth.authorize({ 725 + target, 726 + scope, 727 + prompt: input.signup ? 'create' : undefined 728 + }); 729 + 730 + return { url: url.toString() }; 731 + } catch (e) { 732 + if (e && typeof e === 'object' && 'status' in e) throw e; 733 + const message = e instanceof Error ? e.message : 'Login failed'; 734 + error(400, message); 735 + } 736 + } 737 + ); 738 + 739 + export const oauthLogout = command(async () => { 740 + const { cookies, platform } = getRequestEvent(); 741 + const did = getSignedCookie(cookies, 'did') as Did | null; 742 + 743 + if (did) { 744 + try { 745 + const oauth = createOAuthClient(platform?.env); 746 + await oauth.revoke(did); 747 + } catch (e) { 748 + console.error('Error revoking session:', e); 749 + } 750 + } 751 + 752 + cookies.delete('did', { path: '/' }); 753 + 754 + return { ok: true }; 755 + }); 756 + ``` 757 + 758 + ### `src/lib/atproto/server/repo.remote.ts` 759 + 760 + ```ts 761 + import { error } from '@sveltejs/kit'; 762 + import { command, getRequestEvent } from '$app/server'; 763 + import * as v from 'valibot'; 764 + import { permissions } from '../settings'; 765 + 766 + const collectionSchema = v.pipe( 767 + v.string(), 768 + v.regex(/^[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*$/), 769 + v.check( 770 + (c) => permissions.collections.some((allowed) => c === allowed || allowed.startsWith(c + '?')), 771 + 'Collection not in allowed list' 772 + ) 773 + ); 774 + 775 + const rkeySchema = v.optional(v.pipe(v.string(), v.regex(/^[a-zA-Z0-9._:~-]{1,512}$/))); 776 + 777 + export const putRecord = command( 778 + v.object({ 779 + collection: collectionSchema, 780 + rkey: rkeySchema, 781 + record: v.record(v.string(), v.unknown()) 782 + }), 783 + async (input) => { 784 + const { locals } = getRequestEvent(); 785 + if (!locals.client || !locals.did) error(401, 'Not authenticated'); 786 + 787 + const response = await locals.client.post('com.atproto.repo.putRecord', { 788 + input: { 789 + collection: input.collection as `${string}.${string}.${string}`, 790 + repo: locals.did, 791 + rkey: input.rkey || 'self', 792 + record: input.record 793 + } 794 + }); 795 + 796 + return response.data; 797 + } 798 + ); 799 + 800 + export const deleteRecord = command( 801 + v.object({ 802 + collection: collectionSchema, 803 + rkey: rkeySchema 804 + }), 805 + async (input) => { 806 + const { locals } = getRequestEvent(); 807 + if (!locals.client || !locals.did) error(401, 'Not authenticated'); 808 + 809 + const response = await locals.client.post('com.atproto.repo.deleteRecord', { 810 + input: { 811 + collection: input.collection as `${string}.${string}.${string}`, 812 + repo: locals.did, 813 + rkey: input.rkey || 'self' 814 + } 815 + }); 816 + 817 + return { ok: response.ok }; 818 + } 819 + ); 820 + 821 + export const uploadBlob = command( 822 + v.object({ 823 + blob: v.instance(Blob) 824 + }), 825 + async (input) => { 826 + const { locals } = getRequestEvent(); 827 + if (!locals.client || !locals.did) error(401, 'Not authenticated'); 828 + 829 + const response = await locals.client.post('com.atproto.repo.uploadBlob', { 830 + params: { repo: locals.did }, 831 + input: input.blob 832 + }); 833 + 834 + if (!response.ok) error(500, 'Upload failed'); 835 + 836 + return response.data.blob as { 837 + $type: 'blob'; 838 + ref: { $link: string }; 839 + mimeType: string; 840 + size: number; 841 + }; 842 + } 843 + ); 844 + ``` 845 + 846 + ### `src/lib/atproto/server/session.ts` 847 + 848 + ```ts 849 + import type { Cookies } from '@sveltejs/kit'; 850 + import { Client } from '@atcute/client'; 851 + import type { Did } from '@atcute/lexicons'; 852 + import type { OAuthSession } from '@atcute/oauth-node-client'; 853 + import { createOAuthClient } from './oauth'; 854 + import { getSignedCookie } from './signed-cookie'; 855 + 856 + export type SessionLocals = { 857 + session: OAuthSession | null; 858 + client: Client | null; 859 + did: Did | null; 860 + }; 861 + 862 + export async function restoreSession( 863 + cookies: Cookies, 864 + env?: App.Platform['env'] 865 + ): Promise<SessionLocals> { 866 + const did = getSignedCookie(cookies, 'did') as Did | null; 867 + 868 + if (!did) { 869 + return { session: null, client: null, did: null }; 870 + } 871 + 872 + try { 873 + const oauth = createOAuthClient(env); 874 + const session = await oauth.restore(did); 875 + 876 + return { 877 + session, 878 + client: new Client({ handler: session }), 879 + did 880 + }; 881 + } catch (e) { 882 + console.error('Failed to restore session:', e); 883 + cookies.delete('did', { path: '/' }); 884 + return { session: null, client: null, did: null }; 885 + } 886 + } 887 + ``` 888 + 889 + ### `src/lib/atproto/server/profile.ts` 890 + 891 + ```ts 892 + import type { Did } from '@atcute/lexicons'; 893 + import { getDetailedProfile, describeRepo } from '../methods'; 894 + 895 + const PROFILE_CACHE_TTL = 60 * 60; // 1 hour 896 + 897 + export async function loadProfile(did: Did, profileCache?: KVNamespace) { 898 + if (profileCache) { 899 + try { 900 + const cached = await profileCache.get(did, 'json'); 901 + if (cached) return cached as Record<string, unknown>; 902 + } catch { 903 + // Cache read failed, continue to fresh fetch 904 + } 905 + } 906 + 907 + const profile = await fetchProfile(did); 908 + 909 + if (profileCache && profile) { 910 + profileCache 911 + .put(did, JSON.stringify(profile), { expirationTtl: PROFILE_CACHE_TTL }) 912 + .catch(() => {}); 913 + } 914 + 915 + return profile; 916 + } 917 + 918 + async function fetchProfile(did: Did) { 919 + try { 920 + let profile = await getDetailedProfile({ did }); 921 + 922 + if (!profile || profile.handle === 'handle.invalid') { 923 + const repo = await describeRepo({ did }); 924 + profile = { 925 + did, 926 + handle: repo?.handle || 'handle.invalid' 927 + } as typeof profile; 928 + } 929 + 930 + return profile; 931 + } catch (e) { 932 + console.error('Failed to load profile:', e); 933 + return undefined; 934 + } 935 + } 936 + ``` 937 + 938 + ### `src/lib/atproto/scripts/generate-key.ts` 939 + 940 + ```ts 941 + import { generateClientAssertionKey } from '@atcute/oauth-node-client'; 942 + 943 + const key = await generateClientAssertionKey('main-key'); 944 + console.log(JSON.stringify(key)); 945 + ``` 946 + 947 + ### `src/lib/atproto/scripts/generate-secret.ts` 948 + 949 + ```ts 950 + import { randomBytes } from 'node:crypto'; 951 + 952 + console.log(randomBytes(32).toString('base64url')); 953 + ``` 954 + 955 + ### `src/lib/atproto/scripts/setup-dev.ts` 956 + 957 + ```ts 958 + import { existsSync } from 'node:fs'; 959 + import { copyFile, readFile, writeFile } from 'node:fs/promises'; 960 + import { resolve } from 'node:path'; 961 + import { randomBytes } from 'node:crypto'; 962 + 963 + import { generateClientAssertionKey } from '@atcute/oauth-node-client'; 964 + 965 + const cwd = process.cwd(); 966 + const examplePath = resolve(cwd, '.env.example'); 967 + const envPath = resolve(cwd, '.env'); 968 + 969 + if (!existsSync(envPath)) { 970 + if (!existsSync(examplePath)) { 971 + throw new Error(`missing .env.example (expected at ${examplePath})`); 972 + } 973 + await copyFile(examplePath, envPath); 974 + console.log(`created ${envPath}`); 975 + } 976 + 977 + const upsertVar = (input: string, key: string, value: string): string => { 978 + const line = `${key}=${value}`; 979 + const re = new RegExp(`^${key}=.*$`, 'm'); 980 + 981 + if (re.test(input)) { 982 + const match = input.match(re); 983 + const current = match ? match[0].slice(key.length + 1).trim() : ''; 984 + if (current === '' || current === "''" || current === '""' || current.includes('...')) { 985 + return input.replace(re, line); 986 + } 987 + return input; 988 + } 989 + 990 + const suffix = input.endsWith('\n') || input.length === 0 ? '' : '\n'; 991 + return `${input}${suffix}${line}\n`; 992 + }; 993 + 994 + let vars = await readFile(envPath, 'utf8'); 995 + 996 + const secret = randomBytes(32).toString('base64url'); 997 + vars = upsertVar(vars, 'COOKIE_SECRET', secret); 998 + 999 + const jwk = await generateClientAssertionKey('main-key'); 1000 + vars = upsertVar(vars, 'CLIENT_ASSERTION_KEY', JSON.stringify(jwk)); 1001 + 1002 + await writeFile(envPath, vars); 1003 + console.log(`updated ${envPath}`); 1004 + ``` 1005 + 1006 + ### `src/routes/(oauth)/oauth/callback/+server.ts` 1007 + 1008 + ```ts 1009 + import { redirect } from '@sveltejs/kit'; 1010 + import { createOAuthClient } from '$lib/atproto/server/oauth'; 1011 + import { setSignedCookie } from '$lib/atproto/server/signed-cookie'; 1012 + import { dev } from '$app/environment'; 1013 + import type { RequestHandler } from './$types'; 1014 + 1015 + export const GET: RequestHandler = async ({ url, platform, cookies }) => { 1016 + const oauth = createOAuthClient(platform?.env); 1017 + 1018 + try { 1019 + const { session } = await oauth.callback(url.searchParams); 1020 + 1021 + setSignedCookie(cookies, 'did', session.did, { 1022 + path: '/', 1023 + httpOnly: true, 1024 + secure: !dev, 1025 + sameSite: 'lax', 1026 + maxAge: 60 * 60 * 24 * 180 // 180 days 1027 + }); 1028 + } catch (e) { 1029 + console.error('OAuth callback failed:', e); 1030 + redirect(303, '/?error=auth_failed'); 1031 + } 1032 + 1033 + redirect(303, '/'); 1034 + }; 1035 + ``` 1036 + 1037 + ### `src/routes/(oauth)/oauth/jwks.json/+server.ts` 1038 + 1039 + ```ts 1040 + import { json } from '@sveltejs/kit'; 1041 + import { createOAuthClient } from '$lib/atproto/server/oauth'; 1042 + import type { RequestHandler } from './$types'; 1043 + 1044 + export const GET: RequestHandler = async ({ platform }) => { 1045 + const oauth = createOAuthClient(platform?.env); 1046 + return json(oauth.jwks ?? { keys: [] }); 1047 + }; 1048 + ``` 1049 + 1050 + ### `src/routes/(oauth)/oauth-client-metadata.json/+server.ts` 1051 + 1052 + ```ts 1053 + import { json } from '@sveltejs/kit'; 1054 + import { createOAuthClient } from '$lib/atproto/server/oauth'; 1055 + import type { RequestHandler } from './$types'; 1056 + 1057 + export const GET: RequestHandler = async ({ platform }) => { 1058 + const oauth = createOAuthClient(platform?.env); 1059 + return json(oauth.metadata); 1060 + }; 1061 + ``` 1062 + 1063 + ### `.env.example` 1064 + 1065 + ``` 1066 + # Generate both with: pnpm env:setup-dev 1067 + CLIENT_ASSERTION_KEY= 1068 + COOKIE_SECRET= 1069 + 1070 + # Set to your tunnel URL to use a confidential client in dev 1071 + # OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com 1072 + ``` 1073 + 1074 + ## Step 3: Modify existing files 1075 + 1076 + ### `src/app.d.ts` 1077 + 1078 + Add these to the existing `App` namespace. Merge with any existing `Locals` or `Platform` fields — do not remove existing fields. 1079 + 1080 + ```ts 1081 + import type { OAuthSession } from '@atcute/oauth-node-client'; 1082 + import type { Client } from '@atcute/client'; 1083 + import type { Did } from '@atcute/lexicons'; 1084 + ``` 1085 + 1086 + Add to `App.Locals`: 1087 + 1088 + ```ts 1089 + session: OAuthSession | null; 1090 + client: Client | null; 1091 + did: Did | null; 1092 + ``` 1093 + 1094 + Add to `App.Platform`: 1095 + 1096 + ```ts 1097 + env: { 1098 + OAUTH_SESSIONS: KVNamespace; 1099 + OAUTH_STATES: KVNamespace; 1100 + CLIENT_ASSERTION_KEY: string; 1101 + COOKIE_SECRET: string; 1102 + OAUTH_PUBLIC_URL: string; 1103 + PROFILE_CACHE?: KVNamespace; 1104 + }; 1105 + ``` 1106 + 1107 + Add at the bottom of the file (for lexicon type augmentation): 1108 + 1109 + ```ts 1110 + import type {} from '@atcute/atproto'; 1111 + import type {} from '@atcute/bluesky'; 1112 + ``` 1113 + 1114 + ### `src/hooks.server.ts` 1115 + 1116 + Add session restoration. If the file already has a `handle` export, wrap both in `sequence()` from `@sveltejs/kit`. 1117 + 1118 + ```ts 1119 + import type { Handle } from '@sveltejs/kit'; 1120 + import { restoreSession } from '$lib/atproto/server/session'; 1121 + 1122 + const atprotoHandle: Handle = async ({ event, resolve }) => { 1123 + const { session, client, did } = await restoreSession( 1124 + event.cookies, event.platform?.env 1125 + ); 1126 + event.locals.session = session; 1127 + event.locals.client = client; 1128 + event.locals.did = did; 1129 + return resolve(event); 1130 + }; 1131 + ``` 1132 + 1133 + If no existing hooks: `export const handle = atprotoHandle;` 1134 + 1135 + If existing hooks: `export const handle = sequence(existingHandle, atprotoHandle);` (import `sequence` from `@sveltejs/kit`) 1136 + 1137 + ### `src/routes/+layout.server.ts` 1138 + 1139 + Add profile loading. Merge with any existing load function. 1140 + 1141 + ```ts 1142 + import type { LayoutServerLoad } from './$types'; 1143 + import { loadProfile } from '$lib/atproto/server/profile'; 1144 + 1145 + export const load: LayoutServerLoad = async ({ locals, platform }) => { 1146 + if (!locals.did) return { did: null, profile: null }; 1147 + const profile = await loadProfile(locals.did, platform?.env?.PROFILE_CACHE); 1148 + return { did: locals.did, profile }; 1149 + }; 1150 + ``` 1151 + 1152 + If a load function already exists, merge the profile data into its return value. 1153 + 1154 + ### `src/routes/+layout.svelte` (foxui only) 1155 + 1156 + Only if the user chose `foxui`. Add the login modal to the existing layout: 1157 + 1158 + ```svelte 1159 + <script lang="ts"> 1160 + import { AtprotoLoginModal } from '@foxui/social'; 1161 + import { login, signup } from '$lib/atproto'; 1162 + </script> 1163 + 1164 + <!-- keep existing layout content, add this at the bottom: --> 1165 + <AtprotoLoginModal 1166 + login={async (handle) => { 1167 + await login(handle); 1168 + return true; 1169 + }} 1170 + signup={async () => { 1171 + signup(); 1172 + return true; 1173 + }} 1174 + /> 1175 + ``` 1176 + 1177 + To show the modal from anywhere, use `@foxui/social` state and `@foxui/core` components: 1178 + 1179 + ```svelte 1180 + <script lang="ts"> 1181 + import { Button } from '@foxui/core'; 1182 + import { atProtoLoginModalState } from '@foxui/social'; 1183 + import { user, logout } from '$lib/atproto'; 1184 + </script> 1185 + 1186 + {#if user.isLoggedIn} 1187 + <p>Signed in as {user.profile?.handle ?? user.did}</p> 1188 + <Button onclick={() => logout()}>Sign Out</Button> 1189 + {:else} 1190 + <Button onclick={() => atProtoLoginModalState.show()}>Sign In</Button> 1191 + {/if} 1192 + ``` 1193 + 1194 + `@foxui/core` also exports `Avatar`, `Input`, and other UI primitives you can use. 1195 + 1196 + ### `src/routes/user/+page.svelte` (basic only) 1197 + 1198 + Only if the user chose `basic`. Create this file: 1199 + 1200 + ```svelte 1201 + <script lang="ts"> 1202 + import { user, login, logout } from '$lib/atproto'; 1203 + 1204 + let handle = $state(''); 1205 + let error = $state(''); 1206 + let loading = $state(false); 1207 + 1208 + async function handleLogin() { 1209 + if (!handle.trim()) return; 1210 + loading = true; 1211 + error = ''; 1212 + try { 1213 + await login(handle); 1214 + } catch (e) { 1215 + error = e instanceof Error ? e.message : 'Login failed'; 1216 + loading = false; 1217 + } 1218 + } 1219 + </script> 1220 + 1221 + <div class="mx-auto max-w-sm p-8"> 1222 + {#if user.isLoggedIn} 1223 + <p class="mb-4">Signed in as <strong>{user.profile?.handle ?? user.did}</strong></p> 1224 + <button 1225 + class="rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700" 1226 + onclick={() => logout()} 1227 + > 1228 + Sign Out 1229 + </button> 1230 + {:else} 1231 + <h1 class="mb-4 text-xl font-bold">Sign in</h1> 1232 + <form onsubmit={handleLogin} class="flex flex-col gap-3"> 1233 + <input 1234 + type="text" 1235 + bind:value={handle} 1236 + placeholder="handle.bsky.social" 1237 + class="rounded border px-3 py-2" 1238 + disabled={loading} 1239 + /> 1240 + {#if error} 1241 + <p class="text-sm text-red-600">{error}</p> 1242 + {/if} 1243 + <button 1244 + type="submit" 1245 + class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50" 1246 + disabled={loading || !handle.trim()} 1247 + > 1248 + {loading ? 'Signing in...' : 'Sign In'} 1249 + </button> 1250 + </form> 1251 + {/if} 1252 + </div> 1253 + ``` 1254 + 1255 + If the project does not use Tailwind, replace the Tailwind classes with plain inline styles. 1256 + 1257 + ### `svelte.config.js` 1258 + 1259 + Add `remoteFunctions: true` inside `kit.experimental`: 1260 + 1261 + ```js 1262 + kit: { 1263 + adapter: adapter(), 1264 + experimental: { 1265 + remoteFunctions: true 1266 + } 1267 + } 1268 + ``` 1269 + 1270 + If `experimental` already exists, merge into it. Do not remove other experimental flags. 1271 + 1272 + ### `vite.config.ts` 1273 + 1274 + Add dev server config for loopback OAuth: 1275 + 1276 + ```ts 1277 + server: { 1278 + host: '127.0.0.1', 1279 + port: 5183 1280 + } 1281 + ``` 1282 + 1283 + Add this inside `defineConfig()`. Do not remove existing plugins or config. 1284 + 1285 + ### `wrangler.jsonc` 1286 + 1287 + Add or merge these fields: 1288 + 1289 + - Add `"nodejs_compat_v2"` to `compatibility_flags` (create the array if it doesn't exist) 1290 + - Add `"OAUTH_PUBLIC_URL": "https://your-domain.com"` to `vars` (create `vars` if needed) 1291 + - Add KV namespace placeholders to `kv_namespaces`: 1292 + 1293 + ```jsonc 1294 + { "binding": "OAUTH_SESSIONS", "id": "TODO" }, 1295 + { "binding": "OAUTH_STATES", "id": "TODO" } 1296 + ``` 1297 + 1298 + Do not remove existing bindings or vars. 1299 + 1300 + ### `tsconfig.json` 1301 + 1302 + Add `"@cloudflare/workers-types"` to `compilerOptions.types`. Create the `types` array if it doesn't exist. 1303 + 1304 + ### `package.json` 1305 + 1306 + Add these to the `scripts` section: 1307 + 1308 + ```json 1309 + "env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts", 1310 + "env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts", 1311 + "env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts" 1312 + ``` 1313 + 1314 + ### `.gitignore` 1315 + 1316 + Ensure these lines are present: 1317 + 1318 + ``` 1319 + .env 1320 + .env.* 1321 + !.env.example 1322 + ``` 1323 + 1324 + ## Step 4: Run setup and verify 1325 + 1326 + 1. Run `pnpm env:setup-dev` to generate secrets in `.env` 1327 + 2. Run `pnpm dev` to start the dev server 1328 + 3. Verify it starts on `http://127.0.0.1:5183` 1329 + 4. Tell the user: 1330 + - Dev mode uses a loopback client (no keys needed) 1331 + - 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` 1332 + 1333 + ## Usage examples 1334 + 1335 + ### Login / Logout 1336 + 1337 + ```svelte 1338 + <script lang="ts"> 1339 + import { user, login, logout } from '$lib/atproto'; 1340 + </script> 1341 + 1342 + {#if user.isLoggedIn} 1343 + <p>Signed in as {user.did}</p> 1344 + <button onclick={() => logout()}>Sign Out</button> 1345 + {:else} 1346 + <button onclick={() => login('user.bsky.social')}>Sign In</button> 1347 + {/if} 1348 + ``` 1349 + 1350 + ### Write operations 1351 + 1352 + ```ts 1353 + import { putRecord, deleteRecord, uploadBlob, createTID } from '$lib/atproto'; 1354 + 1355 + await putRecord({ 1356 + collection: 'your.collection.name', 1357 + rkey: createTID(), 1358 + record: { text: 'hello', createdAt: new Date().toISOString() } 1359 + }); 1360 + 1361 + await deleteRecord({ collection: 'your.collection.name', rkey: 'some-key' }); 1362 + 1363 + const blob = await uploadBlob({ blob: file }); 1364 + ``` 1365 + 1366 + ### Read operations (no auth needed) 1367 + 1368 + ```ts 1369 + import { listRecords, getRecord, getDetailedProfile } from '$lib/atproto'; 1370 + 1371 + const records = await listRecords({ did: 'did:plc:...', collection: 'your.collection.name' }); 1372 + const profile = await getDetailedProfile({ did: 'did:plc:...' }); 1373 + ```
+84 -103
README.md
··· 1 - # svelte atproto cloudflare workers oauth demo 2 - 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 - 5 - ## Prerequisites 6 - 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) 1 + # svelte atproto cloudflare workers oauth 12 2 13 - ## Local Development 3 + SvelteKit + AT Protocol OAuth on Cloudflare Workers. Server-side OAuth with `@atcute/oauth-node-client`, Cloudflare KV for session/state storage, and SvelteKit remote functions for type-safe client-server communication. 14 4 15 - ### 1. Install dependencies 5 + ## Quick Start 16 6 17 7 ```sh 18 8 pnpm install 9 + pnpm dev 19 10 ``` 20 11 21 - ### 2. Run the dev server 12 + In dev mode the app uses a **loopback OAuth client** (no keys, in-memory storage). It binds to `127.0.0.1:5183` — required for AT Protocol loopback OAuth. 13 + 14 + ### Dev with tunnel (confidential client) 15 + 16 + To test the full production flow locally with a tunnel like [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/): 22 17 23 18 ```sh 24 - pnpm dev 19 + pnpm env:setup-dev # generates secrets in .env 20 + # add OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com to .env 21 + cloudflared tunnel --url http://localhost:5183 # start tunnel 22 + pnpm dev # start dev server 25 23 ``` 26 24 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. 30 - 31 25 ## Production Deployment 32 26 33 27 ### 1. Create KV namespaces 34 28 35 - Create two KV namespaces for storing OAuth sessions and states: 36 - 37 29 ```sh 38 30 npx wrangler kv namespace create OAUTH_SESSIONS 39 31 npx wrangler kv namespace create OAUTH_STATES 40 32 ``` 41 33 42 - Each command outputs an ID. Update `wrangler.toml` with the actual IDs: 34 + Add the IDs to `wrangler.jsonc`: 43 35 44 - ```toml 45 - [[kv_namespaces]] 46 - binding = "OAUTH_SESSIONS" 47 - id = "<your-sessions-namespace-id>" 48 - 49 - [[kv_namespaces]] 50 - binding = "OAUTH_STATES" 51 - id = "<your-states-namespace-id>" 36 + ```jsonc 37 + "kv_namespaces": [ 38 + { "binding": "OAUTH_SESSIONS", "id": "<your-id>" }, 39 + { "binding": "OAUTH_STATES", "id": "<your-id>" } 40 + ] 52 41 ``` 53 42 54 - ### 2. Generate a client assertion key 43 + ### 2. Set your public URL 55 44 56 - AT Protocol OAuth requires a confidential client with a private key for production. Generate one: 45 + In `wrangler.jsonc`: 57 46 58 - ```sh 59 - pnpm generate-key 47 + ```jsonc 48 + "vars": { 49 + "OAUTH_PUBLIC_URL": "https://your-domain.com" 50 + } 60 51 ``` 61 52 62 - This outputs a JSON key. Add it as a Cloudflare Workers secret: 53 + ### 3. Generate and set secrets 63 54 64 55 ```sh 65 - npx wrangler secret put CLIENT_ASSERTION_KEY 56 + pnpm env:generate-key 57 + npx wrangler secret put CLIENT_ASSERTION_KEY # paste generated key 58 + 59 + pnpm env:generate-secret 60 + npx wrangler secret put COOKIE_SECRET # paste generated secret 66 61 ``` 67 62 68 - When prompted, paste the JSON key value. 63 + ### 4. Configure permissions 69 64 70 - ### 3. Configure your domain 71 - 72 - Edit `src/lib/atproto/settings.ts` and set `SITE` to your production domain: 65 + Edit `src/lib/atproto/settings.ts`: 73 66 74 67 ```ts 75 - export const SITE = dev ? 'http://localhost:5183' : 'https://your-domain.com'; 68 + export const permissions = { 69 + collections: ['xyz.statusphere.status'], // collections your app can read/write 70 + rpc: {}, // authenticated RPC requests 71 + blobs: [] // blob types your app can upload 72 + } as const; 76 73 ``` 77 74 78 - ### 4. Serve the OAuth client metadata 75 + The OAuth scope is auto-generated from this config. 79 76 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`. 77 + ### 5. Deploy 81 78 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: 83 - 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 - } 79 + ```sh 80 + npx wrangler deploy 91 81 ``` 92 82 93 - The scope is auto-generated from the `permissions` config in `src/lib/atproto/settings.ts`. 83 + Set up a custom domain in the Cloudflare dashboard (Worker > Settings > Domains & Routes) so the OAuth client metadata URL matches your `client_id`. 94 84 95 - ### 5. Configure permissions 85 + ## Scripts 96 86 97 - Edit the `permissions` object in `src/lib/atproto/settings.ts` to control what your app can do: 87 + | Script | Description | 88 + |---|---| 89 + | `pnpm dev` | Start dev server | 90 + | `pnpm build` | Build for production | 91 + | `pnpm check` | Run svelte-check | 92 + | `pnpm env:generate-key` | Generate client assertion key | 93 + | `pnpm env:generate-secret` | Generate cookie signing secret | 94 + | `pnpm env:setup-dev` | Generate both and write to `.env` | 98 95 99 - ```ts 100 - export const permissions = { 101 - // collections your app can create/delete/update records in 102 - collections: ['xyz.statusphere.status'], 96 + ## Adding to an existing project 103 97 104 - // authenticated proxied RPC requests (e.g. to appview services) 105 - rpc: {}, 98 + **With an AI agent** — paste this into Claude Code (or similar) in your existing repo: 106 99 107 - // blob types your app can upload 108 - blobs: [] 109 - } as const; 110 100 ``` 111 - 112 - ### 6. Deploy 113 - 114 - ```sh 115 - pnpm build 116 - npx wrangler deploy 101 + add atproto oauth to this project https://raw.githubusercontent.com/flo-bit/svelte-atproto-oauth-cloudflare-workers/main/AGENT_SETUP.md 117 102 ``` 118 103 119 - Or deploy directly: 104 + The [agent prompt](AGENT_SETUP.md) will ask you a few questions and set everything up. 120 105 121 - ```sh 122 - npx wrangler deploy 123 - ``` 124 - 125 - ### 7. Set up a custom domain (recommended) 126 - 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`. 106 + **Manually** — see [SETUP.md](SETUP.md) for a step-by-step guide. 128 107 129 108 ## Project Structure 130 109 131 110 ``` 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 111 + src/lib/atproto/ 112 + ├── auth.svelte.ts # Client-side auth state & login/logout/signup 113 + ├── index.ts # Public exports 114 + ├── metadata.ts # OAuth scope from permissions 115 + ├── methods.ts # AT Protocol helpers (read/write/resolve) 116 + ├── settings.ts # Permissions config, constants 117 + ├── server/ 118 + │ ├── oauth.ts # OAuthClient factory (loopback vs confidential) 119 + │ ├── oauth.remote.ts # Remote functions: login, logout 120 + │ ├── repo.remote.ts # Remote functions: putRecord, deleteRecord, uploadBlob 121 + │ ├── session.ts # Session restoration from signed cookie 122 + │ ├── profile.ts # Profile loading with optional KV cache 123 + │ ├── kv-store.ts # Cloudflare KV-backed Store 124 + │ └── signed-cookie.ts # HMAC-signed cookie helpers 125 + └── scripts/ 126 + ├── generate-key.ts 127 + ├── generate-secret.ts 128 + └── setup-dev.ts 129 + 130 + src/routes/(oauth)/ 131 + ├── oauth/callback/+server.ts 132 + ├── oauth/jwks.json/+server.ts 133 + └── oauth-client-metadata.json/+server.ts 153 134 ``` 154 135 155 136 ## How It Works 156 137 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. 138 + - **Auth**: Server-side OAuth via `@atcute/oauth-node-client`. Sessions stored in KV, identified by HMAC-signed `did` cookie. 139 + - **Remote functions**: Write operations and auth actions use SvelteKit remote functions — type-safe server calls without manual API routes. 140 + - **Dev mode**: Loopback client by default. Set `OAUTH_PUBLIC_URL` in `.env` for confidential client via tunnel. 141 + - **Prod mode**: Confidential client with `private_key_jwt`, KV stores, `OAUTH_PUBLIC_URL` from `wrangler.jsonc`. 161 142 162 143 ## License 163 144
+276
SETUP.md
··· 1 + # Adding AT Protocol OAuth to your SvelteKit + Cloudflare Workers project 2 + 3 + ## 1. Install dependencies 4 + 5 + ```sh 6 + pnpm add valibot 7 + pnpm add -D @atcute/oauth-node-client @atcute/identity-resolver @atcute/lexicons @atcute/client @atcute/tid @cloudflare/workers-types tsx 8 + ``` 9 + 10 + Add any lexicon types you need (e.g. `@atcute/atproto`, `@atcute/bluesky`). 11 + 12 + ## 2. Copy files 13 + 14 + Copy these into your project: 15 + 16 + - `src/lib/atproto/` — auth state, methods, server logic, scripts 17 + - `src/routes/(oauth)/` — OAuth callback, JWKS, and client metadata endpoints 18 + 19 + ## 3. Configure 20 + 21 + **`src/lib/atproto/settings.ts`** — set your app's permissions: 22 + 23 + ```ts 24 + export const permissions = { 25 + collections: ['your.collection.name'], 26 + rpc: {}, 27 + blobs: [] 28 + } as const; 29 + ``` 30 + 31 + The OAuth scope is auto-generated from this config. 32 + 33 + **`src/app.d.ts`** — add session types: 34 + 35 + ```ts 36 + import type { OAuthSession } from '@atcute/oauth-node-client'; 37 + import type { Client } from '@atcute/client'; 38 + import type { Did } from '@atcute/lexicons'; 39 + 40 + declare global { 41 + namespace App { 42 + interface Locals { 43 + session: OAuthSession | null; 44 + client: Client | null; 45 + did: Did | null; 46 + } 47 + interface Platform { 48 + env: { 49 + OAUTH_SESSIONS: KVNamespace; 50 + OAUTH_STATES: KVNamespace; 51 + CLIENT_ASSERTION_KEY: string; 52 + COOKIE_SECRET: string; 53 + OAUTH_PUBLIC_URL: string; 54 + PROFILE_CACHE?: KVNamespace; // optional 55 + }; 56 + } 57 + } 58 + } 59 + 60 + import type {} from '@atcute/atproto'; 61 + import type {} from '@atcute/bluesky'; 62 + export {}; 63 + ``` 64 + 65 + **`src/hooks.server.ts`** — restore session on every request: 66 + 67 + ```ts 68 + import type { Handle } from '@sveltejs/kit'; 69 + import { restoreSession } from '$lib/atproto/server/session'; 70 + 71 + export const handle: Handle = async ({ event, resolve }) => { 72 + const { session, client, did } = await restoreSession( 73 + event.cookies, event.platform?.env 74 + ); 75 + event.locals.session = session; 76 + event.locals.client = client; 77 + event.locals.did = did; 78 + return resolve(event); 79 + }; 80 + ``` 81 + 82 + If you already have hooks, use SvelteKit's `sequence` helper to combine them. 83 + 84 + **`wrangler.jsonc`** — add KV namespaces and public URL: 85 + 86 + ```sh 87 + npx wrangler kv namespace create OAUTH_SESSIONS 88 + npx wrangler kv namespace create OAUTH_STATES 89 + ``` 90 + 91 + ```jsonc 92 + { 93 + "compatibility_flags": ["nodejs_compat_v2"], 94 + "vars": { 95 + "OAUTH_PUBLIC_URL": "https://your-domain.com" 96 + }, 97 + "kv_namespaces": [ 98 + { "binding": "OAUTH_SESSIONS", "id": "<your-id>" }, 99 + { "binding": "OAUTH_STATES", "id": "<your-id>" } 100 + ] 101 + } 102 + ``` 103 + 104 + **`package.json`** — add helper scripts: 105 + 106 + ```json 107 + { 108 + "env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts", 109 + "env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts", 110 + "env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts" 111 + } 112 + ``` 113 + 114 + **`.env.example`**: 115 + 116 + ``` 117 + CLIENT_ASSERTION_KEY= 118 + COOKIE_SECRET= 119 + # Set to your tunnel URL to use a confidential client in dev 120 + OAUTH_PUBLIC_URL= 121 + ``` 122 + 123 + ## 4. Load profile (optional) 124 + 125 + Add a `src/routes/+layout.server.ts` to load the user's Bluesky profile on every page: 126 + 127 + ```ts 128 + import type { LayoutServerLoad } from './$types'; 129 + import { loadProfile } from '$lib/atproto/server/profile'; 130 + 131 + export const load: LayoutServerLoad = async ({ locals, platform }) => { 132 + if (!locals.did) return { did: null, profile: null }; 133 + const profile = await loadProfile(locals.did, platform?.env?.PROFILE_CACHE); 134 + return { did: locals.did, profile }; 135 + }; 136 + ``` 137 + 138 + For optional profile caching, create a KV namespace and add it to `wrangler.jsonc`: 139 + 140 + ```sh 141 + npx wrangler kv namespace create PROFILE_CACHE 142 + ``` 143 + 144 + ## 5. Generate secrets 145 + 146 + For local dev: 147 + 148 + ```sh 149 + pnpm env:setup-dev 150 + ``` 151 + 152 + For production: 153 + 154 + ```sh 155 + pnpm env:generate-key 156 + npx wrangler secret put CLIENT_ASSERTION_KEY # paste the generated key 157 + 158 + pnpm env:generate-secret 159 + npx wrangler secret put COOKIE_SECRET # paste the generated secret 160 + ``` 161 + 162 + ## 6. Add login UI 163 + 164 + ### Option A: `@foxui/social` login modal (recommended) 165 + 166 + Install the UI packages: 167 + 168 + ```sh 169 + pnpm add @foxui/social @foxui/core 170 + ``` 171 + 172 + Add the login modal to your root layout (`src/routes/+layout.svelte`): 173 + 174 + ```svelte 175 + <script lang="ts"> 176 + import { AtprotoLoginModal } from '@foxui/social'; 177 + import { login, signup } from '$lib/atproto'; 178 + 179 + let { children } = $props(); 180 + </script> 181 + 182 + {@render children()} 183 + 184 + <AtprotoLoginModal 185 + login={async (handle) => { 186 + await login(handle); 187 + return true; 188 + }} 189 + signup={async () => { 190 + signup(); 191 + return true; 192 + }} 193 + /> 194 + ``` 195 + 196 + Then open the modal from anywhere: 197 + 198 + ```svelte 199 + <script lang="ts"> 200 + import { Button } from '@foxui/core'; 201 + import { atProtoLoginModalState } from '@foxui/social'; 202 + import { user, logout } from '$lib/atproto'; 203 + </script> 204 + 205 + {#if user.isLoggedIn} 206 + <p>Signed in as {user.profile?.handle ?? user.did}</p> 207 + <Button onclick={() => logout()}>Sign Out</Button> 208 + {:else} 209 + <Button onclick={() => atProtoLoginModalState.show()}>Sign In</Button> 210 + {/if} 211 + ``` 212 + 213 + ### Option B: Simple inline login 214 + 215 + ```svelte 216 + <script lang="ts"> 217 + import { user, login, logout } from '$lib/atproto'; 218 + </script> 219 + 220 + {#if user.isLoggedIn} 221 + <p>Signed in as {user.did}</p> 222 + <button onclick={() => logout()}>Sign Out</button> 223 + {:else} 224 + <button onclick={() => login('user.bsky.social')}>Sign In</button> 225 + {/if} 226 + ``` 227 + 228 + ### Write operations 229 + 230 + ```ts 231 + import { putRecord, deleteRecord, uploadBlob } from '$lib/atproto'; 232 + 233 + await putRecord({ 234 + collection: 'your.collection.name', 235 + rkey: 'some-key', 236 + record: { text: 'hello', createdAt: new Date().toISOString() } 237 + }); 238 + 239 + await deleteRecord({ collection: 'your.collection.name', rkey: 'some-key' }); 240 + 241 + const blob = await uploadBlob({ blob: file }); 242 + ``` 243 + 244 + ### Read operations (no auth needed) 245 + 246 + ```ts 247 + import { listRecords, getRecord, getDetailedProfile } from '$lib/atproto'; 248 + 249 + const records = await listRecords({ did: 'did:plc:...', collection: 'your.collection.name' }); 250 + const profile = await getDetailedProfile({ did: 'did:plc:...' }); 251 + ``` 252 + 253 + ### Server load functions 254 + 255 + ```ts 256 + export const load = async ({ locals }) => { 257 + if (!locals.client || !locals.did) return { data: null }; 258 + 259 + const response = await locals.client.get('com.atproto.repo.listRecords', { 260 + params: { repo: locals.did, collection: 'your.collection.name' } 261 + }); 262 + 263 + return { data: response.data }; 264 + }; 265 + ``` 266 + 267 + ## Dev with tunnel (optional) 268 + 269 + To test the confidential client flow locally: 270 + 271 + 1. `pnpm env:setup-dev` 272 + 2. Add tunnel URL to `.env`: `OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com` 273 + 3. `cloudflared tunnel --url http://localhost:5183` 274 + 4. `pnpm dev` 275 + 276 + Without `OAUTH_PUBLIC_URL`, dev mode uses a loopback public client (no keys needed).
+6 -1
package.json
··· 12 12 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 13 "format": "prettier --write .", 14 14 "lint": "prettier --check . && eslint .", 15 - "generate-key": "npx tsx scripts/generate-key.ts" 15 + "env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts", 16 + "env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts", 17 + "env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts" 16 18 }, 17 19 "devDependencies": { 18 20 "@atcute/atproto": "^3.1.10", ··· 49 51 }, 50 52 "license": "MIT", 51 53 "dependencies": { 54 + "@foxui/core": "^0.5.1", 55 + "@foxui/social": "^0.5.1", 56 + "@foxui/time": "^0.5.1", 52 57 "valibot": "^1.2.0" 53 58 } 54 59 }
+357
pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@foxui/core': 12 + specifier: ^0.5.1 13 + version: 0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18) 14 + '@foxui/social': 15 + specifier: ^0.5.1 16 + version: 0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18) 17 + '@foxui/time': 18 + specifier: ^0.5.1 19 + version: 0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18) 11 20 valibot: 12 21 specifier: ^1.2.0 13 22 version: 1.2.0(typescript@5.9.3) ··· 166 175 167 176 '@atcute/util-text@1.1.1': 168 177 resolution: {integrity: sha512-JH0SxzUQJAmbOBTYyhxQbkkI6M33YpjlVLEcbP5GYt43xgFArzV0FJVmEpvIj0kjsmphHB45b6IitdvxPdec9w==, tarball: https://registry.npmjs.org/@atcute/util-text/-/util-text-1.1.1.tgz} 178 + 179 + '@atproto/api@0.18.21': 180 + resolution: {integrity: sha512-s35MIJerGT/pKe2xJtKKswqlIr/ola2r2iURBKBL0Mk1OKe6jP4YvTMh1N2d2PEANFzNNTbKoDaLfJPo2Uvc/w==, tarball: https://registry.npmjs.org/@atproto/api/-/api-0.18.21.tgz} 181 + 182 + '@atproto/common-web@0.4.17': 183 + resolution: {integrity: sha512-sfxD8NGxyoxhxmM9EUshEFbWcJ3+JHEOZF4Quk6HsCh1UxpHBmLabT/vEsAkDWl+C/8U0ine0+c/gHyE/OZiQQ==, tarball: https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.17.tgz} 184 + 185 + '@atproto/lex-data@0.0.12': 186 + resolution: {integrity: sha512-aekJudcK1p6sbTqUv2bJMJBAGZaOJS0mgDclpK3U6VuBREK/au4B6ffunBFWgrDfg0Vwj2JGyEA7E51WZkJcRw==, tarball: https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.12.tgz} 187 + 188 + '@atproto/lex-json@0.0.12': 189 + resolution: {integrity: sha512-XlEpnWWZdDJ5BIgG25GyH+6iBfyrFL18BI5JSE6rUfMObbFMrQRaCuRLQfryRXNysVz3L3U+Qb9y8KcXbE8AcA==, tarball: https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.12.tgz} 190 + 191 + '@atproto/lexicon@0.6.1': 192 + resolution: {integrity: sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==, tarball: https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.1.tgz} 193 + 194 + '@atproto/syntax@0.4.3': 195 + resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==, tarball: https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz} 196 + 197 + '@atproto/xrpc@0.7.7': 198 + resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==, tarball: https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz} 169 199 170 200 '@badrap/valita@0.4.6': 171 201 resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==, tarball: https://registry.npmjs.org/@badrap/valita/-/valita-0.4.6.tgz} ··· 596 626 '@floating-ui/utils@0.2.10': 597 627 resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==, tarball: https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz} 598 628 629 + '@foxui/core@0.5.1': 630 + resolution: {integrity: sha512-WhcwTYy2bipsfrBmNjHF88gfmigr10KeWqNBVrwWmkFjAeXy8J9rj9CSLpeRQCHMmrNDqjVq7VahxWRuz0NO9g==, tarball: https://registry.npmjs.org/@foxui/core/-/core-0.5.1.tgz} 631 + peerDependencies: 632 + svelte: '>=5' 633 + tailwindcss: '>=3' 634 + 635 + '@foxui/social@0.5.1': 636 + resolution: {integrity: sha512-PupbByfLdykQqzxjTZkMpy43LHaLrivI0DEb9Psew0+1PCMDqFSoDk00BVl5vtaPOlBfOwdQsDICa4kR0LApHg==, tarball: https://registry.npmjs.org/@foxui/social/-/social-0.5.1.tgz} 637 + peerDependencies: 638 + svelte: '>=5' 639 + tailwindcss: '>=3' 640 + 641 + '@foxui/time@0.5.1': 642 + resolution: {integrity: sha512-P7mLM0UVarRdAI0owUUVzsa+VTp+ZQWENNM9BD0H9VESG49dIwP6sbfCRFFecCLijUHP+aIcnO3bnuK9BDx3rQ==, tarball: https://registry.npmjs.org/@foxui/time/-/time-0.5.1.tgz} 643 + peerDependencies: 644 + svelte: '>=5' 645 + tailwindcss: '>=3' 646 + 599 647 '@humanfs/core@0.19.1': 600 648 resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==, tarball: https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz} 601 649 engines: {node: '>=18.18.0'} ··· 770 818 771 819 '@jridgewell/trace-mapping@0.3.9': 772 820 resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz} 821 + 822 + '@number-flow/svelte@0.3.13': 823 + resolution: {integrity: sha512-mvbxDeSFa1o/E4vGhrWuawAFCgcn5qTQ/s++FIoD88es5+JQa/aMQUypTy7qXIreTtTvncpIbkKdw9DMnweaSw==, tarball: https://registry.npmjs.org/@number-flow/svelte/-/svelte-0.3.13.tgz} 824 + peerDependencies: 825 + svelte: ^4 || ^5 773 826 774 827 '@polka/url@1.0.0-next.29': 775 828 resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==, tarball: https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz} ··· 1132 1185 resolution: {integrity: sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==, tarball: https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.1.tgz} 1133 1186 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 1134 1187 1188 + '@use-gesture/core@10.3.1': 1189 + resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==, tarball: https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz} 1190 + 1191 + '@use-gesture/vanilla@10.3.1': 1192 + resolution: {integrity: sha512-lT4scGLu59ovA3zmtUonukAGcA0AdOOh+iwNDS05Bsu7Lq9aZToDHhI6D8Q2qvsVraovtsLLYwPrWdG/noMAKw==, tarball: https://registry.npmjs.org/@use-gesture/vanilla/-/vanilla-10.3.1.tgz} 1193 + 1135 1194 acorn-jsx@5.3.2: 1136 1195 resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==, tarball: https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz} 1137 1196 peerDependencies: ··· 1156 1215 resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==, tarball: https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz} 1157 1216 engines: {node: '>= 0.4'} 1158 1217 1218 + await-lock@2.2.2: 1219 + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==, tarball: https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz} 1220 + 1159 1221 axobject-query@4.1.0: 1160 1222 resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==, tarball: https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz} 1161 1223 engines: {node: '>= 0.4'} ··· 1170 1232 '@internationalized/date': ^3.8.1 1171 1233 svelte: ^5.33.0 1172 1234 1235 + bits-ui@2.16.2: 1236 + resolution: {integrity: sha512-bgEpRRF7Ck9nRP1pbuKVxpaSMrz+8Pm0y+dmuvlkrSe+uUwIQECef29y6eslFHM6pCAubUh7STrsTLUUp8fzFQ==, tarball: https://registry.npmjs.org/bits-ui/-/bits-ui-2.16.2.tgz} 1237 + engines: {node: '>=20'} 1238 + peerDependencies: 1239 + '@internationalized/date': ^3.8.1 1240 + svelte: ^5.33.0 1241 + 1173 1242 blake3-wasm@2.1.5: 1174 1243 resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==, tarball: https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz} 1175 1244 ··· 1216 1285 resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==, tarball: https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz} 1217 1286 engines: {node: '>=18'} 1218 1287 1288 + core-js@3.48.0: 1289 + resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==, tarball: https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz} 1290 + 1219 1291 cross-spawn@7.0.6: 1220 1292 resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, tarball: https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz} 1221 1293 engines: {node: '>= 8'} ··· 1224 1296 resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==, tarball: https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz} 1225 1297 engines: {node: '>=4'} 1226 1298 hasBin: true 1299 + 1300 + custom-event-polyfill@1.0.7: 1301 + resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==, tarball: https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz} 1227 1302 1228 1303 debug@4.4.3: 1229 1304 resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==, tarball: https://registry.npmjs.org/debug/-/debug-4.4.3.tgz} ··· 1251 1326 1252 1327 devalue@5.6.2: 1253 1328 resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==, tarball: https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz} 1329 + 1330 + emoji-picker-element@1.29.0: 1331 + resolution: {integrity: sha512-lQm8YayfwIP5j+Xe1O2Fjul7hv2b4spPS16X99O4qKQdzDQaEeiAqqYaRONHncewcpisUf6qGFJkhM2G3riEdA==, tarball: https://registry.npmjs.org/emoji-picker-element/-/emoji-picker-element-1.29.0.tgz} 1254 1332 1255 1333 enhanced-resolve@5.18.4: 1256 1334 resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==, tarball: https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz} ··· 1401 1479 resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, tarball: https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz} 1402 1480 engines: {node: '>=8'} 1403 1481 1482 + hls.js@1.6.15: 1483 + resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==, tarball: https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz} 1484 + 1404 1485 ignore@5.3.2: 1405 1486 resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, tarball: https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz} 1406 1487 engines: {node: '>= 4'} ··· 1420 1501 inline-style-parser@0.2.7: 1421 1502 resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==, tarball: https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz} 1422 1503 1504 + is-emoji-supported@0.0.5: 1505 + resolution: {integrity: sha512-WOlXUhDDHxYqcSmFZis+xWhhqXiK2SU0iYiqmth5Ip0FHLZQAt9rKL5ahnilE8/86WH8tZ3bmNNNC+bTzamqlw==, tarball: https://registry.npmjs.org/is-emoji-supported/-/is-emoji-supported-0.0.5.tgz} 1506 + 1423 1507 is-extglob@2.1.1: 1424 1508 resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, tarball: https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz} 1425 1509 engines: {node: '>=0.10.0'} ··· 1433 1517 1434 1518 isexe@2.0.0: 1435 1519 resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, tarball: https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz} 1520 + 1521 + iso-datestring-validator@2.2.2: 1522 + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==, tarball: https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz} 1436 1523 1437 1524 jiti@2.6.1: 1438 1525 resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==, tarball: https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz} ··· 1538 1625 lilconfig@2.1.0: 1539 1626 resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==, tarball: https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz} 1540 1627 engines: {node: '>=10'} 1628 + 1629 + loadjs@4.3.0: 1630 + resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==, tarball: https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz} 1541 1631 1542 1632 locate-character@3.0.0: 1543 1633 resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==, tarball: https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz} ··· 1572 1662 resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==, tarball: https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz} 1573 1663 engines: {node: '>=16 || 14 >=14.17'} 1574 1664 1665 + mode-watcher@1.1.0: 1666 + resolution: {integrity: sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==, tarball: https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz} 1667 + peerDependencies: 1668 + svelte: ^5.27.0 1669 + 1575 1670 mri@1.2.0: 1576 1671 resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==, tarball: https://registry.npmjs.org/mri/-/mri-1.2.0.tgz} 1577 1672 engines: {node: '>=4'} ··· 1583 1678 ms@2.1.3: 1584 1679 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, tarball: https://registry.npmjs.org/ms/-/ms-2.1.3.tgz} 1585 1680 1681 + multiformats@9.9.0: 1682 + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==, tarball: https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz} 1683 + 1586 1684 nanoid@3.3.11: 1587 1685 resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz} 1588 1686 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} ··· 1599 1697 node-gyp-build@4.8.4: 1600 1698 resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==, tarball: https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz} 1601 1699 hasBin: true 1700 + 1701 + number-flow@0.5.12: 1702 + resolution: {integrity: sha512-CIs21h2JkfYG4rfgERaUNAk0Cz+Ef14fNJfSCbGGhgRgconQc9b7rcCQfi9SZ36kNjVXmsl2BrzDbjGtEgumAA==, tarball: https://registry.npmjs.org/number-flow/-/number-flow-0.5.12.tgz} 1602 1703 1603 1704 obug@2.1.1: 1604 1705 resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==, tarball: https://registry.npmjs.org/obug/-/obug-2.1.1.tgz} ··· 1640 1741 resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz} 1641 1742 engines: {node: '>=12'} 1642 1743 1744 + plyr@3.8.4: 1745 + resolution: {integrity: sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==, tarball: https://registry.npmjs.org/plyr/-/plyr-3.8.4.tgz} 1746 + 1643 1747 postcss-load-config@3.1.4: 1644 1748 resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==, tarball: https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz} 1645 1749 engines: {node: '>= 10'} ··· 1746 1850 resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, tarball: https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz} 1747 1851 engines: {node: '>=6'} 1748 1852 1853 + rangetouch@2.0.1: 1854 + resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==, tarball: https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz} 1855 + 1749 1856 readdirp@4.1.2: 1750 1857 resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz} 1751 1858 engines: {node: '>= 14.18.0'} ··· 1766 1873 engines: {node: '>=18.0.0', npm: '>=8.0.0'} 1767 1874 hasBin: true 1768 1875 1876 + runed@0.23.4: 1877 + resolution: {integrity: sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==, tarball: https://registry.npmjs.org/runed/-/runed-0.23.4.tgz} 1878 + peerDependencies: 1879 + svelte: ^5.7.0 1880 + 1881 + runed@0.25.0: 1882 + resolution: {integrity: sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==, tarball: https://registry.npmjs.org/runed/-/runed-0.25.0.tgz} 1883 + peerDependencies: 1884 + svelte: ^5.7.0 1885 + 1886 + runed@0.28.0: 1887 + resolution: {integrity: sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==, tarball: https://registry.npmjs.org/runed/-/runed-0.28.0.tgz} 1888 + peerDependencies: 1889 + svelte: ^5.7.0 1890 + 1769 1891 runed@0.35.1: 1770 1892 resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==, tarball: https://registry.npmjs.org/runed/-/runed-0.35.1.tgz} 1771 1893 peerDependencies: ··· 1839 1961 svelte: 1840 1962 optional: true 1841 1963 1964 + svelte-sonner@1.0.7: 1965 + resolution: {integrity: sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A==, tarball: https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.0.7.tgz} 1966 + peerDependencies: 1967 + svelte: ^5.0.0 1968 + 1842 1969 svelte-toolbelt@0.10.6: 1843 1970 resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==, tarball: https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz} 1844 1971 engines: {node: '>=18', pnpm: '>=8.7.0'} 1845 1972 peerDependencies: 1846 1973 svelte: ^5.30.2 1847 1974 1975 + svelte-toolbelt@0.7.1: 1976 + resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==, tarball: https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz} 1977 + engines: {node: '>=18', pnpm: '>=8.7.0'} 1978 + peerDependencies: 1979 + svelte: ^5.0.0 1980 + 1848 1981 svelte@5.48.0: 1849 1982 resolution: {integrity: sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==, tarball: https://registry.npmjs.org/svelte/-/svelte-5.48.0.tgz} 1850 1983 engines: {node: '>=18'} ··· 1852 1985 tabbable@6.4.0: 1853 1986 resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==, tarball: https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz} 1854 1987 1988 + tailwind-merge@3.5.0: 1989 + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==, tarball: https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz} 1990 + 1991 + tailwind-variants@3.2.2: 1992 + resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==, tarball: https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz} 1993 + engines: {node: '>=16.x', pnpm: '>=7.x'} 1994 + peerDependencies: 1995 + tailwind-merge: '>=3.0.0' 1996 + tailwindcss: '*' 1997 + peerDependenciesMeta: 1998 + tailwind-merge: 1999 + optional: true 2000 + 1855 2001 tailwindcss@4.1.18: 1856 2002 resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==, tarball: https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz} 1857 2003 ··· 1863 2009 resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz} 1864 2010 engines: {node: '>=12.0.0'} 1865 2011 2012 + tlds@1.261.0: 2013 + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==, tarball: https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz} 2014 + hasBin: true 2015 + 1866 2016 totalist@3.0.1: 1867 2017 resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==, tarball: https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz} 1868 2018 engines: {node: '>=6'} ··· 1897 2047 engines: {node: '>=14.17'} 1898 2048 hasBin: true 1899 2049 2050 + uint8arrays@3.0.0: 2051 + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==, tarball: https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz} 2052 + 1900 2053 undici-types@7.16.0: 1901 2054 resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz} 1902 2055 ··· 1912 2065 1913 2066 uri-js@4.4.1: 1914 2067 resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, tarball: https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz} 2068 + 2069 + url-polyfill@1.1.14: 2070 + resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==, tarball: https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz} 1915 2071 1916 2072 util-deprecate@1.0.2: 1917 2073 resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, tarball: https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz} ··· 2029 2185 zimmerframe@1.1.4: 2030 2186 resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==, tarball: https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz} 2031 2187 2188 + zod@3.25.76: 2189 + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz} 2190 + 2032 2191 snapshots: 2033 2192 2034 2193 '@atcute/atproto@3.1.10': ··· 2131 2290 dependencies: 2132 2291 unicode-segmenter: 0.14.5 2133 2292 2293 + '@atproto/api@0.18.21': 2294 + dependencies: 2295 + '@atproto/common-web': 0.4.17 2296 + '@atproto/lexicon': 0.6.1 2297 + '@atproto/syntax': 0.4.3 2298 + '@atproto/xrpc': 0.7.7 2299 + await-lock: 2.2.2 2300 + multiformats: 9.9.0 2301 + tlds: 1.261.0 2302 + zod: 3.25.76 2303 + 2304 + '@atproto/common-web@0.4.17': 2305 + dependencies: 2306 + '@atproto/lex-data': 0.0.12 2307 + '@atproto/lex-json': 0.0.12 2308 + '@atproto/syntax': 0.4.3 2309 + zod: 3.25.76 2310 + 2311 + '@atproto/lex-data@0.0.12': 2312 + dependencies: 2313 + multiformats: 9.9.0 2314 + tslib: 2.8.1 2315 + uint8arrays: 3.0.0 2316 + unicode-segmenter: 0.14.5 2317 + 2318 + '@atproto/lex-json@0.0.12': 2319 + dependencies: 2320 + '@atproto/lex-data': 0.0.12 2321 + tslib: 2.8.1 2322 + 2323 + '@atproto/lexicon@0.6.1': 2324 + dependencies: 2325 + '@atproto/common-web': 0.4.17 2326 + '@atproto/syntax': 0.4.3 2327 + iso-datestring-validator: 2.2.2 2328 + multiformats: 9.9.0 2329 + zod: 3.25.76 2330 + 2331 + '@atproto/syntax@0.4.3': 2332 + dependencies: 2333 + tslib: 2.8.1 2334 + 2335 + '@atproto/xrpc@0.7.7': 2336 + dependencies: 2337 + '@atproto/lexicon': 0.6.1 2338 + zod: 3.25.76 2339 + 2134 2340 '@badrap/valita@0.4.6': {} 2135 2341 2136 2342 '@cloudflare/kv-asset-handler@0.4.2': {} ··· 2390 2596 2391 2597 '@floating-ui/utils@0.2.10': {} 2392 2598 2599 + '@foxui/core@0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18)': 2600 + dependencies: 2601 + '@number-flow/svelte': 0.3.13(svelte@5.48.0) 2602 + bits-ui: 2.16.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0) 2603 + clsx: 2.1.1 2604 + mode-watcher: 1.1.0(svelte@5.48.0) 2605 + svelte: 5.48.0 2606 + svelte-sonner: 1.0.7(svelte@5.48.0) 2607 + tailwind-merge: 3.5.0 2608 + tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.1.18) 2609 + tailwindcss: 4.1.18 2610 + transitivePeerDependencies: 2611 + - '@internationalized/date' 2612 + - '@sveltejs/kit' 2613 + 2614 + '@foxui/social@0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18)': 2615 + dependencies: 2616 + '@atproto/api': 0.18.21 2617 + '@foxui/core': 0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18) 2618 + '@foxui/time': 0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18) 2619 + '@use-gesture/vanilla': 10.3.1 2620 + bits-ui: 2.16.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0) 2621 + emoji-picker-element: 1.29.0 2622 + hls.js: 1.6.15 2623 + is-emoji-supported: 0.0.5 2624 + plyr: 3.8.4 2625 + svelte: 5.48.0 2626 + tailwindcss: 4.1.18 2627 + transitivePeerDependencies: 2628 + - '@internationalized/date' 2629 + - '@sveltejs/kit' 2630 + 2631 + '@foxui/time@0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18)': 2632 + dependencies: 2633 + '@foxui/core': 0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18) 2634 + '@number-flow/svelte': 0.3.13(svelte@5.48.0) 2635 + bits-ui: 2.16.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0) 2636 + svelte: 5.48.0 2637 + tailwindcss: 4.1.18 2638 + transitivePeerDependencies: 2639 + - '@internationalized/date' 2640 + - '@sveltejs/kit' 2641 + 2393 2642 '@humanfs/core@0.19.1': {} 2394 2643 2395 2644 '@humanfs/node@0.16.7': ··· 2524 2773 dependencies: 2525 2774 '@jridgewell/resolve-uri': 3.1.2 2526 2775 '@jridgewell/sourcemap-codec': 1.5.5 2776 + 2777 + '@number-flow/svelte@0.3.13(svelte@5.48.0)': 2778 + dependencies: 2779 + esm-env: 1.2.2 2780 + number-flow: 0.5.12 2781 + svelte: 5.48.0 2527 2782 2528 2783 '@polka/url@1.0.0-next.29': {} 2529 2784 ··· 2851 3106 '@typescript-eslint/types': 8.53.1 2852 3107 eslint-visitor-keys: 4.2.1 2853 3108 3109 + '@use-gesture/core@10.3.1': {} 3110 + 3111 + '@use-gesture/vanilla@10.3.1': 3112 + dependencies: 3113 + '@use-gesture/core': 10.3.1 3114 + 2854 3115 acorn-jsx@5.3.2(acorn@8.15.0): 2855 3116 dependencies: 2856 3117 acorn: 8.15.0 ··· 2872 3133 2873 3134 aria-query@5.3.2: {} 2874 3135 3136 + await-lock@2.2.2: {} 3137 + 2875 3138 axobject-query@4.1.0: {} 2876 3139 2877 3140 balanced-match@1.0.2: {} ··· 2889 3152 transitivePeerDependencies: 2890 3153 - '@sveltejs/kit' 2891 3154 3155 + bits-ui@2.16.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0): 3156 + dependencies: 3157 + '@floating-ui/core': 1.7.3 3158 + '@floating-ui/dom': 1.7.4 3159 + '@internationalized/date': 3.10.1 3160 + esm-env: 1.2.2 3161 + runed: 0.35.1(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0) 3162 + svelte: 5.48.0 3163 + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0) 3164 + tabbable: 6.4.0 3165 + transitivePeerDependencies: 3166 + - '@sveltejs/kit' 3167 + 2892 3168 blake3-wasm@2.1.5: {} 2893 3169 2894 3170 brace-expansion@1.1.12: ··· 2929 3205 2930 3206 cookie@1.1.1: {} 2931 3207 3208 + core-js@3.48.0: {} 3209 + 2932 3210 cross-spawn@7.0.6: 2933 3211 dependencies: 2934 3212 path-key: 3.1.1 ··· 2936 3214 which: 2.0.2 2937 3215 2938 3216 cssesc@3.0.0: {} 3217 + 3218 + custom-event-polyfill@1.0.7: {} 2939 3219 2940 3220 debug@4.4.3: 2941 3221 dependencies: ··· 2951 3231 2952 3232 devalue@5.6.2: {} 2953 3233 3234 + emoji-picker-element@1.29.0: {} 3235 + 2954 3236 enhanced-resolve@5.18.4: 2955 3237 dependencies: 2956 3238 graceful-fs: 4.2.11 ··· 3161 3443 3162 3444 has-flag@4.0.0: {} 3163 3445 3446 + hls.js@1.6.15: {} 3447 + 3164 3448 ignore@5.3.2: {} 3165 3449 3166 3450 ignore@7.0.5: {} ··· 3174 3458 3175 3459 inline-style-parser@0.2.7: {} 3176 3460 3461 + is-emoji-supported@0.0.5: {} 3462 + 3177 3463 is-extglob@2.1.1: {} 3178 3464 3179 3465 is-glob@4.0.3: ··· 3185 3471 '@types/estree': 1.0.8 3186 3472 3187 3473 isexe@2.0.0: {} 3474 + 3475 + iso-datestring-validator@2.2.2: {} 3188 3476 3189 3477 jiti@2.6.1: {} 3190 3478 ··· 3262 3550 3263 3551 lilconfig@2.1.0: {} 3264 3552 3553 + loadjs@4.3.0: {} 3554 + 3265 3555 locate-character@3.0.0: {} 3266 3556 3267 3557 locate-path@6.0.0: ··· 3298 3588 dependencies: 3299 3589 brace-expansion: 2.0.2 3300 3590 3591 + mode-watcher@1.1.0(svelte@5.48.0): 3592 + dependencies: 3593 + runed: 0.25.0(svelte@5.48.0) 3594 + svelte: 5.48.0 3595 + svelte-toolbelt: 0.7.1(svelte@5.48.0) 3596 + 3301 3597 mri@1.2.0: {} 3302 3598 3303 3599 mrmime@2.0.1: {} 3304 3600 3305 3601 ms@2.1.3: {} 3306 3602 3603 + multiformats@9.9.0: {} 3604 + 3307 3605 nanoid@3.3.11: {} 3308 3606 3309 3607 nanoid@5.1.6: {} ··· 3311 3609 natural-compare@1.4.0: {} 3312 3610 3313 3611 node-gyp-build@4.8.4: {} 3612 + 3613 + number-flow@0.5.12: 3614 + dependencies: 3615 + esm-env: 1.2.2 3314 3616 3315 3617 obug@2.1.1: {} 3316 3618 ··· 3347 3649 3348 3650 picomatch@4.0.3: {} 3349 3651 3652 + plyr@3.8.4: 3653 + dependencies: 3654 + core-js: 3.48.0 3655 + custom-event-polyfill: 1.0.7 3656 + loadjs: 4.3.0 3657 + rangetouch: 2.0.1 3658 + url-polyfill: 1.1.14 3659 + 3350 3660 postcss-load-config@3.1.4(postcss@8.5.6): 3351 3661 dependencies: 3352 3662 lilconfig: 2.1.0 ··· 3390 3700 3391 3701 punycode@2.3.1: {} 3392 3702 3703 + rangetouch@2.0.1: {} 3704 + 3393 3705 readdirp@4.1.2: {} 3394 3706 3395 3707 regexparam@3.0.0: {} ··· 3429 3741 '@rollup/rollup-win32-x64-msvc': 4.56.0 3430 3742 fsevents: 2.3.3 3431 3743 3744 + runed@0.23.4(svelte@5.48.0): 3745 + dependencies: 3746 + esm-env: 1.2.2 3747 + svelte: 5.48.0 3748 + 3749 + runed@0.25.0(svelte@5.48.0): 3750 + dependencies: 3751 + esm-env: 1.2.2 3752 + svelte: 5.48.0 3753 + 3754 + runed@0.28.0(svelte@5.48.0): 3755 + dependencies: 3756 + esm-env: 1.2.2 3757 + svelte: 5.48.0 3758 + 3432 3759 runed@0.35.1(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0): 3433 3760 dependencies: 3434 3761 dequal: 2.0.3 ··· 3526 3853 optionalDependencies: 3527 3854 svelte: 5.48.0 3528 3855 3856 + svelte-sonner@1.0.7(svelte@5.48.0): 3857 + dependencies: 3858 + runed: 0.28.0(svelte@5.48.0) 3859 + svelte: 5.48.0 3860 + 3529 3861 svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0): 3530 3862 dependencies: 3531 3863 clsx: 2.1.1 ··· 3535 3867 transitivePeerDependencies: 3536 3868 - '@sveltejs/kit' 3537 3869 3870 + svelte-toolbelt@0.7.1(svelte@5.48.0): 3871 + dependencies: 3872 + clsx: 2.1.1 3873 + runed: 0.23.4(svelte@5.48.0) 3874 + style-to-object: 1.0.14 3875 + svelte: 5.48.0 3876 + 3538 3877 svelte@5.48.0: 3539 3878 dependencies: 3540 3879 '@jridgewell/remapping': 2.3.5 ··· 3555 3894 3556 3895 tabbable@6.4.0: {} 3557 3896 3897 + tailwind-merge@3.5.0: {} 3898 + 3899 + tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.1.18): 3900 + dependencies: 3901 + tailwindcss: 4.1.18 3902 + optionalDependencies: 3903 + tailwind-merge: 3.5.0 3904 + 3558 3905 tailwindcss@4.1.18: {} 3559 3906 3560 3907 tapable@2.3.0: {} ··· 3563 3910 dependencies: 3564 3911 fdir: 6.5.0(picomatch@4.0.3) 3565 3912 picomatch: 4.0.3 3913 + 3914 + tlds@1.261.0: {} 3566 3915 3567 3916 totalist@3.0.1: {} 3568 3917 ··· 3596 3945 3597 3946 typescript@5.9.3: {} 3598 3947 3948 + uint8arrays@3.0.0: 3949 + dependencies: 3950 + multiformats: 9.9.0 3951 + 3599 3952 undici-types@7.16.0: {} 3600 3953 3601 3954 undici@7.18.2: {} ··· 3609 3962 uri-js@4.4.1: 3610 3963 dependencies: 3611 3964 punycode: 2.3.1 3965 + 3966 + url-polyfill@1.1.14: {} 3612 3967 3613 3968 util-deprecate@1.0.2: {} 3614 3969 ··· 3691 4046 youch-core: 0.3.3 3692 4047 3693 4048 zimmerframe@1.1.4: {} 4049 + 4050 + zod@3.25.76: {}
-10
scripts/generate-key.ts
··· 1 - import { generateClientAssertionKey } from '@atcute/oauth-node-client'; 2 - 3 - const key = await generateClientAssertionKey('main-key'); 4 - const json = JSON.stringify(key); 5 - 6 - console.log('Generated client assertion key.\n'); 7 - console.log('Set it as a Cloudflare Workers secret:\n'); 8 - console.log(' npx wrangler secret put CLIENT_ASSERTION_KEY\n'); 9 - console.log('Then paste this value:\n'); 10 - console.log(json);
+1
src/app.css
··· 1 1 @import 'tailwindcss'; 2 2 3 3 @plugin '@tailwindcss/forms'; 4 + @source "../node_modules/@foxui"; 4 5 5 6 /* @custom-variant dark (&:where(.dark, .dark *)); */ 6 7
+4 -1
src/app.d.ts
··· 10 10 interface Locals { 11 11 session: OAuthSession | null; 12 12 client: Client | null; 13 - did: Did | undefined; 13 + did: Did | null; 14 14 } 15 15 // interface PageData {} 16 16 // interface PageState {} ··· 19 19 OAUTH_SESSIONS: KVNamespace; 20 20 OAUTH_STATES: KVNamespace; 21 21 CLIENT_ASSERTION_KEY: string; 22 + COOKIE_SECRET: string; 23 + OAUTH_PUBLIC_URL: string; 24 + PROFILE_CACHE?: KVNamespace; 22 25 }; 23 26 } 24 27 }
+8 -21
src/hooks.server.ts
··· 1 1 import type { Handle } from '@sveltejs/kit'; 2 - import { createOAuthClient } from '$lib/server/oauth'; 3 - import { Client } from '@atcute/client'; 4 - import type { Did } from '@atcute/lexicons'; 2 + import { restoreSession } from '$lib/atproto/server/session'; 5 3 6 4 export const handle: Handle = async ({ event, resolve }) => { 7 - event.locals.session = null; 8 - event.locals.client = null; 9 - event.locals.did = undefined; 5 + const { session, client, did } = await restoreSession( 6 + event.cookies, 7 + event.platform?.env 8 + ); 10 9 11 - const did = event.cookies.get('did') as Did | undefined; 12 - 13 - if (did) { 14 - try { 15 - const oauth = createOAuthClient(event.platform?.env); 16 - const session = await oauth.restore(did); 17 - 18 - event.locals.session = session; 19 - event.locals.client = new Client({ handler: session }); 20 - event.locals.did = did; 21 - } catch (e) { 22 - console.error('Failed to restore session:', e); 23 - event.cookies.delete('did', { path: '/' }); 24 - } 25 - } 10 + event.locals.session = session; 11 + event.locals.client = client; 12 + event.locals.did = did; 26 13 27 14 return resolve(event); 28 15 };
-66
src/lib/atproto/UI/Avatar.svelte
··· 1 - <script lang="ts"> 2 - import { Avatar as AvatarPrimitive, type WithoutChildrenOrChild } from 'bits-ui'; 3 - 4 - let { 5 - src, 6 - alt, 7 - fallback, 8 - ref = $bindable(null), 9 - 10 - imageRef = $bindable(null), 11 - imageClass, 12 - 13 - fallbackRef = $bindable(null), 14 - fallbackClass, 15 - 16 - class: className, 17 - ...restProps 18 - }: WithoutChildrenOrChild<AvatarPrimitive.RootProps> & { 19 - fallback?: string; 20 - imageRef?: HTMLImageElement | null; 21 - imageClass?: string; 22 - fallbackRef?: HTMLElement | null; 23 - fallbackClass?: string; 24 - 25 - src?: string; 26 - alt?: string; 27 - } = $props(); 28 - </script> 29 - 30 - <div 31 - class={[ 32 - 'border-base-300 bg-base-200 text-base-900 dark:border-base-800 dark:bg-base-900 dark:text-base-50 relative isolate flex size-6 shrink-0 overflow-hidden rounded-full border', 33 - className 34 - ]} 35 - {...restProps} 36 - bind:this={ref} 37 - > 38 - {#if fallback} 39 - <span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 font-medium" 40 - >{fallback}</span 41 - > 42 - {:else} 43 - <svg 44 - xmlns="http://www.w3.org/2000/svg" 45 - viewBox="0 0 24 24" 46 - fill="currentColor" 47 - class="text-base-400 dark:text-base-600 absolute top-1/2 left-1/2 mt-[15%] size-full -translate-x-1/2 -translate-y-1/2" 48 - > 49 - <path 50 - fill-rule="evenodd" 51 - d="M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z" 52 - clip-rule="evenodd" 53 - /> 54 - </svg> 55 - {/if} 56 - {#if src} 57 - <img 58 - {src} 59 - alt={alt ?? ''} 60 - class="z-10 aspect-square size-full object-cover" 61 - onerror={() => { 62 - imageRef?.classList.add('hidden'); 63 - }} 64 - /> 65 - {/if} 66 - </div>
-23
src/lib/atproto/UI/Button.svelte
··· 1 - <script lang="ts"> 2 - import type { HTMLButtonAttributes } from 'svelte/elements'; 3 - 4 - type Props = HTMLButtonAttributes & { 5 - children: () => any; 6 - ref?: HTMLButtonElement | null; 7 - 8 - }; 9 - 10 - let { children, ref = $bindable(), class: className, ...props }: Props = $props(); 11 - </script> 12 - 13 - <button 14 - bind:this={ref} 15 - class={[ 16 - 'bg-accent-600 hover:bg-accent-500 focus-visible:outline-accent-600 text-white', 17 - 'inline-flex cursor-pointer justify-center rounded-full px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 18 - className 19 - ]} 20 - {...props} 21 - > 22 - {@render children()} 23 - </button>
-90
src/lib/atproto/UI/HandleInput.svelte
··· 1 - <script lang="ts"> 2 - import { AppBskyActorDefs } from '@atcute/bluesky'; 3 - import { Combobox } from 'bits-ui'; 4 - import { searchActorsTypeahead } from '$lib/atproto'; 5 - import Avatar from './Avatar.svelte'; 6 - 7 - let results: AppBskyActorDefs.ProfileViewBasic[] = $state([]); 8 - 9 - async function search(q: string) { 10 - if (!q || q.length < 2) { 11 - results = []; 12 - return; 13 - } 14 - results = (await searchActorsTypeahead(q, 5)).actors; 15 - } 16 - let open = $state(false); 17 - 18 - let { 19 - value = $bindable(), 20 - onselected, 21 - ref = $bindable() 22 - }: { 23 - value: string; 24 - onselected: (actor: AppBskyActorDefs.ProfileViewBasic) => void; 25 - ref?: HTMLInputElement | null; 26 - } = $props(); 27 - </script> 28 - 29 - <Combobox.Root 30 - type="single" 31 - onOpenChangeComplete={(o) => { 32 - if (!o) results = []; 33 - }} 34 - bind:value={ 35 - () => { 36 - return value; 37 - }, 38 - (val) => { 39 - const profile = results.find((v) => v.handle === val); 40 - if (profile) onselected?.(profile); 41 - 42 - value = val; 43 - } 44 - } 45 - bind:open={ 46 - () => { 47 - return open && results.length > 0; 48 - }, 49 - (val) => { 50 - open = val; 51 - } 52 - } 53 - > 54 - <Combobox.Input 55 - bind:ref 56 - oninput={(e) => { 57 - value = e.currentTarget.value; 58 - search(e.currentTarget.value); 59 - }} 60 - class="w-full touch-none rounded-full border-0 bg-white ring-0 outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-accent-600 dark:bg-white/5 dark:outline-white/10 dark:focus-within:outline-accent-500 dark:placeholder:text-base-400" 61 - placeholder="handle" 62 - id="" 63 - aria-label="enter your handle" 64 - /> 65 - <Combobox.Content 66 - class="z-100 max-h-[30dvh] w-full rounded-2xl border border-base-300 bg-base-50 dark:bg-base-900 dark:border-base-800 shadow-lg" 67 - sideOffset={10} 68 - align="start" 69 - side="top" 70 - > 71 - <Combobox.Viewport class="w-full p-1"> 72 - {#each results as actor (actor.did)} 73 - <Combobox.Item 74 - class="rounded-button my-0.5 flex w-full cursor-pointer items-center gap-2 rounded-xl p-2 px-2 data-highlighted:bg-accent-100 dark:data-highlighted:bg-accent-600/30" 75 - value={actor.handle} 76 - label={actor.handle} 77 - > 78 - {#snippet children()} 79 - <Avatar 80 - src={actor.avatar?.replace('avatar', 'avatar_thumbnail')} 81 - alt="" 82 - class="size-6 rounded-full" 83 - /> 84 - {actor.handle} 85 - {/snippet} 86 - </Combobox.Item> 87 - {/each} 88 - </Combobox.Viewport> 89 - </Combobox.Content> 90 - </Combobox.Root>
-273
src/lib/atproto/UI/LoginModal.svelte
··· 1 - <script lang="ts" module> 2 - export const loginModalState = $state({ 3 - visible: false, 4 - show: () => (loginModalState.visible = true), 5 - hide: () => (loginModalState.visible = false) 6 - }); 7 - </script> 8 - 9 - <script lang="ts"> 10 - import { login, signup } from '$lib/atproto'; 11 - import type { ActorIdentifier, Did } from '@atcute/lexicons'; 12 - import Button from './Button.svelte'; 13 - import { onMount, tick } from 'svelte'; 14 - import SecondaryButton from './SecondaryButton.svelte'; 15 - import HandleInput from './HandleInput.svelte'; 16 - import { AppBskyActorDefs } from '@atcute/bluesky'; 17 - import Avatar from './Avatar.svelte'; 18 - 19 - let { signUp = true, loginOnSelect = true }: { signUp?: boolean; loginOnSelect?: boolean } = 20 - $props(); 21 - 22 - function extractErrorMessage(err: unknown): string { 23 - const msg = err instanceof Error ? err.message : String(err); 24 - try { 25 - const parsed = JSON.parse(msg); 26 - if (typeof parsed?.message === 'string') return parsed.message; 27 - } catch {} 28 - return msg; 29 - } 30 - 31 - let value = $state(''); 32 - let error: string | null = $state(null); 33 - let loadingLogin = $state(false); 34 - let loadingSignup = $state(false); 35 - 36 - async function onSubmit(event?: Event) { 37 - event?.preventDefault(); 38 - if (loadingLogin) return; 39 - 40 - error = null; 41 - loadingLogin = true; 42 - 43 - try { 44 - await login(value as ActorIdentifier); 45 - } catch (err) { 46 - error = extractErrorMessage(err); 47 - } finally { 48 - loadingLogin = false; 49 - } 50 - } 51 - 52 - let input: HTMLInputElement | null = $state(null); 53 - let submitButton: HTMLButtonElement | null = $state(null); 54 - 55 - $effect(() => { 56 - if (!loginModalState.visible) { 57 - error = null; 58 - value = ''; 59 - loadingLogin = false; 60 - selectedActor = undefined; 61 - } else { 62 - focusInput(); 63 - } 64 - }); 65 - 66 - function focusInput() { 67 - tick().then(() => { 68 - input?.focus(); 69 - }); 70 - } 71 - function focusSubmit() { 72 - tick().then(() => { 73 - submitButton?.focus(); 74 - }); 75 - } 76 - 77 - let selectedActor: AppBskyActorDefs.ProfileViewBasic | undefined = $state(); 78 - 79 - let recentLogins: Record<Did, AppBskyActorDefs.ProfileViewBasic> = $state({}); 80 - 81 - onMount(() => { 82 - try { 83 - recentLogins = JSON.parse(localStorage.getItem('recent-logins') || '{}'); 84 - } catch {} 85 - }); 86 - 87 - function removeRecentLogin(did: Did) { 88 - try { 89 - delete recentLogins[did]; 90 - 91 - localStorage.setItem('recent-logins', JSON.stringify(recentLogins)); 92 - } catch {} 93 - } 94 - 95 - let recentLoginsView = $state(true); 96 - 97 - let showRecentLogins = $derived( 98 - Object.keys(recentLogins).length > 0 && !loadingLogin && !selectedActor && recentLoginsView 99 - ); 100 - </script> 101 - 102 - {#if loginModalState.visible} 103 - <div 104 - class="fixed inset-0 z-100 w-screen overflow-y-auto" 105 - aria-labelledby="modal-title" 106 - role="dialog" 107 - aria-modal="true" 108 - > 109 - <div 110 - class="bg-base-50/90 dark:bg-base-950/90 fixed inset-0 backdrop-blur-sm transition-opacity" 111 - onclick={() => (loginModalState.visible = false)} 112 - aria-hidden="true" 113 - ></div> 114 - 115 - <div class="pointer-events-none fixed inset-0 isolate z-10 w-screen overflow-y-auto"> 116 - <div 117 - class="flex min-h-full w-screen items-end justify-center p-4 text-center sm:items-center sm:p-0" 118 - > 119 - <div 120 - class="border-base-200 bg-base-100 dark:border-base-700 dark:bg-base-800 pointer-events-auto relative w-full transform overflow-hidden rounded-2xl border px-4 pt-4 pb-4 text-left shadow-xl transition-all sm:my-8 sm:max-w-sm sm:p-6" 121 - > 122 - <h3 class="text-base-900 dark:text-base-100 font-semibold" id="modal-title"> 123 - Login with your internet handle 124 - </h3> 125 - 126 - <div class="text-base-800 dark:text-base-200 mt-2 mb-2 text-xs font-light"> 127 - e.g. your bluesky account 128 - </div> 129 - 130 - <form onsubmit={onSubmit} class="mt-2 flex w-full flex-col gap-2"> 131 - {#if showRecentLogins} 132 - <div class="mt-2 mb-2 text-sm font-medium">Recent logins</div> 133 - <div class="flex flex-col gap-2"> 134 - {#each Object.values(recentLogins) 135 - .filter((l) => l.handle && l.handle !== 'handle.invalid') 136 - .slice(0, 4) as recentLogin} 137 - <div class="group"> 138 - <div 139 - class="group-hover:bg-base-300 bg-base-200 dark:bg-base-700 dark:hover:bg-base-600 dark:border-base-500/50 border-base-300 relative flex h-10 w-full items-center justify-between gap-2 rounded-full border px-2 font-semibold transition-colors duration-100" 140 - > 141 - <div class="flex items-center gap-2"> 142 - <Avatar class="size-6" src={recentLogin.avatar} /> 143 - {recentLogin.handle} 144 - </div> 145 - <button 146 - class="z-20 cursor-pointer" 147 - onclick={() => { 148 - value = recentLogin.handle; 149 - selectedActor = recentLogin; 150 - if (loginOnSelect) onSubmit(); 151 - else focusSubmit(); 152 - }} 153 - > 154 - <div class="absolute inset-0 h-full w-full"></div> 155 - <span class="sr-only">login</span> 156 - </button> 157 - 158 - <button 159 - onclick={() => { 160 - removeRecentLogin(recentLogin.did); 161 - }} 162 - class="z-30 cursor-pointer rounded-full p-0.5" 163 - > 164 - <svg 165 - xmlns="http://www.w3.org/2000/svg" 166 - fill="none" 167 - viewBox="0 0 24 24" 168 - stroke-width="1.5" 169 - stroke="currentColor" 170 - class="size-3" 171 - > 172 - <path 173 - stroke-linecap="round" 174 - stroke-linejoin="round" 175 - d="M6 18 18 6M6 6l12 12" 176 - /> 177 - </svg> 178 - <span class="sr-only">sign in with other account</span> 179 - </button> 180 - </div> 181 - </div> 182 - {/each} 183 - </div> 184 - {:else if !selectedActor} 185 - <div class="mt-4 w-full"> 186 - <HandleInput 187 - bind:value 188 - onselected={(a) => { 189 - selectedActor = a; 190 - value = a.handle; 191 - if (loginOnSelect) onSubmit(); 192 - else focusSubmit(); 193 - }} 194 - bind:ref={input} 195 - /> 196 - </div> 197 - {:else} 198 - <div 199 - class="bg-base-200 dark:bg-base-700 border-base-300 dark:border-base-600 mt-4 flex h-10 w-full items-center justify-between gap-2 rounded-full border px-2 font-semibold" 200 - > 201 - <div class="flex items-center gap-2"> 202 - <Avatar class="size-6" src={selectedActor.avatar} /> 203 - {selectedActor.handle} 204 - </div> 205 - 206 - <button 207 - onclick={() => { 208 - selectedActor = undefined; 209 - value = ''; 210 - }} 211 - type="button" 212 - class="cursor-pointer rounded-full p-0.5" 213 - > 214 - <svg 215 - xmlns="http://www.w3.org/2000/svg" 216 - fill="none" 217 - viewBox="0 0 24 24" 218 - stroke-width="1.5" 219 - stroke="currentColor" 220 - class="size-3" 221 - > 222 - <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 223 - </svg> 224 - <span class="sr-only">sign in with other account</span> 225 - </button> 226 - </div> 227 - {/if} 228 - 229 - {#if error} 230 - <p class="text-accent-500 text-sm font-semibold">{error}</p> 231 - {/if} 232 - 233 - <div class="mt-4"> 234 - {#if showRecentLogins} 235 - <div class="mt-2 mb-4 text-sm font-medium">Or login with new handle</div> 236 - 237 - <Button 238 - onclick={() => { 239 - recentLoginsView = false; 240 - focusInput(); 241 - }} 242 - class="w-full">Login with new handle</Button 243 - > 244 - {:else} 245 - <Button bind:ref={submitButton} type="submit" disabled={loadingLogin} class="w-full" 246 - >{loadingLogin ? 'Loading...' : 'Login'}</Button 247 - > 248 - {/if} 249 - </div> 250 - 251 - {#if signUp} 252 - <div 253 - class="border-base-200 dark:border-base-700 text-base-800 dark:text-base-200 mt-4 border-t pt-4 text-sm leading-7" 254 - > 255 - Don't have an account? 256 - <div class="mt-3"> 257 - <SecondaryButton 258 - onclick={async () => { 259 - loadingSignup = true; 260 - await signup(); 261 - }} 262 - disabled={loadingSignup} 263 - class="w-full">{loadingSignup ? 'Loading...' : 'Sign Up'}</SecondaryButton 264 - > 265 - </div> 266 - </div> 267 - {/if} 268 - </form> 269 - </div> 270 - </div> 271 - </div> 272 - </div> 273 - {/if}
-20
src/lib/atproto/UI/SecondaryButton.svelte
··· 1 - <script lang="ts"> 2 - import type { HTMLButtonAttributes } from 'svelte/elements'; 3 - 4 - type Props = HTMLButtonAttributes & { 5 - children: () => any; 6 - }; 7 - 8 - let { children, class: className, ...props }: Props = $props(); 9 - </script> 10 - 11 - <button 12 - class={[ 13 - 'bg-base-300 dark:bg-base-700 dark:text-base-50 dark:hover:bg-base-600 text-black transition-colors duration-100 hover:bg-base-200 focus-visible:outline-base-600', 14 - 'inline-flex cursor-pointer justify-center rounded-full px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 15 - className 16 - ]} 17 - {...props} 18 - > 19 - {@render children()} 20 - </button>
-1
src/lib/atproto/UI/index.ts
··· 1 - export { default as LoginModal, loginModalState } from './LoginModal.svelte';
+6 -6
src/lib/atproto/auth.svelte.ts
··· 4 4 5 5 export const user = { 6 6 get profile() { 7 - return page.data?.profile as AppBskyActorDefs.ProfileViewDetailed | undefined; 7 + return (page.data?.profile as AppBskyActorDefs.ProfileViewDetailed | null) ?? null; 8 8 }, 9 9 get isLoggedIn() { 10 10 return !!page.data?.did; 11 11 }, 12 12 get did() { 13 - return page.data?.did as Did | undefined; 13 + return (page.data?.did as Did | null) ?? null; 14 14 } 15 15 }; 16 16 17 - export async function login(handle: ActorIdentifier) { 17 + export async function login(handle: string) { 18 18 if (handle.startsWith('did:')) { 19 19 if (handle.length < 6) throw new Error('DID must be at least 6 characters'); 20 20 } else if (handle.includes('.') && handle.length > 3) { ··· 27 27 throw new Error('Please provide a valid handle or DID.'); 28 28 } 29 29 30 - const { oauthLogin } = await import('./oauth.remote'); 30 + const { oauthLogin } = await import('./server/oauth.remote'); 31 31 const { url } = await oauthLogin({ handle }); 32 32 window.location.assign(url); 33 33 ··· 40 40 } 41 41 42 42 export async function signup() { 43 - const { oauthLogin } = await import('./oauth.remote'); 43 + const { oauthLogin } = await import('./server/oauth.remote'); 44 44 const { url } = await oauthLogin({ signup: true }); 45 45 window.location.assign(url); 46 46 ··· 53 53 54 54 export async function logout() { 55 55 try { 56 - const { oauthLogout } = await import('./oauth.remote'); 56 + const { oauthLogout } = await import('./server/oauth.remote'); 57 57 await oauthLogout(); 58 58 } catch (e) { 59 59 console.error('Error logging out:', e);
+2 -2
src/lib/atproto/index.ts
··· 1 1 export { user, login, signup, logout } from './auth.svelte'; 2 - export { metadata } from './metadata'; 3 2 4 3 export { 5 4 parseUri, ··· 15 14 describeRepo, 16 15 getBlobURL, 17 16 getCDNImageBlobUrl, 18 - searchActorsTypeahead 17 + searchActorsTypeahead, 18 + createTID 19 19 } from './methods';
+12 -25
src/lib/atproto/metadata.ts
··· 1 - import { permissions, REDIRECT_PATH, SITE } from './settings'; 1 + import { permissions } from './settings'; 2 2 3 3 function constructScope() { 4 - const repos = permissions.collections.map((collection) => 'repo:' + collection).join(' '); 4 + const parts: string[] = ['atproto']; 5 5 6 - let rpcs = ''; 6 + for (const collection of permissions.collections) { 7 + parts.push('repo:' + collection); 8 + } 9 + 7 10 for (const [key, value] of Object.entries(permissions.rpc ?? {})) { 8 - if (Array.isArray(value)) { 9 - rpcs += value.map((lxm) => 'rpc?lxm=' + lxm + '&aud=' + key).join(' '); 10 - } else { 11 - rpcs += 'rpc?lxm=' + value + '&aud=' + key; 11 + const lxms = Array.isArray(value) ? value : [value]; 12 + for (const lxm of lxms) { 13 + parts.push('rpc?lxm=' + lxm + '&aud=' + key); 12 14 } 13 15 } 14 16 15 - let blobScope: string | undefined = undefined; 16 - if (Array.isArray(permissions.blobs) && permissions.blobs.length > 0) { 17 - blobScope = 'blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&'); 18 - } else if (permissions.blobs && permissions.blobs.length > 0) { 19 - blobScope = 'blob:' + permissions.blobs; 17 + if (permissions.blobs.length > 0) { 18 + parts.push('blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&')); 20 19 } 21 20 22 - const scope = ['atproto', repos, rpcs, blobScope].filter((v) => v?.trim()).join(' '); 23 - return scope; 21 + return parts.join(' '); 24 22 } 25 23 26 24 export const scope = constructScope(); 27 - 28 - export function constructMetadata() { 29 - return { 30 - client_id: SITE + '/oauth-client-metadata.json', 31 - redirect_uris: [SITE + REDIRECT_PATH] as [string], 32 - scope, 33 - jwks_uri: SITE + '/oauth/jwks.json' 34 - }; 35 - } 36 - 37 - export const metadata = constructMetadata();
+11 -11
src/lib/atproto/methods.ts
··· 1 1 import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons'; 2 2 import { user } from './auth.svelte'; 3 - import type { AllowedCollection } from './settings'; 3 + import { DOH_RESOLVER, type AllowedCollection } from './settings'; 4 4 import { 5 5 CompositeDidDocumentResolver, 6 6 CompositeHandleResolver, ··· 30 30 export async function resolveHandle({ handle }: { handle: Handle }) { 31 31 const handleResolver = new CompositeHandleResolver({ 32 32 methods: { 33 - dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }), 33 + dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }), 34 34 http: new WellKnownHandleResolver() 35 35 } 36 36 }); ··· 64 64 */ 65 65 export async function getDetailedProfile(data?: { did?: Did; client?: Client }) { 66 66 data ??= {}; 67 - data.did ??= user.did; 67 + data.did ??= user.did ?? undefined; 68 68 69 69 if (!data.did) throw new Error('Error getting detailed profile: no did'); 70 70 ··· 111 111 limit?: number; 112 112 client?: Client; 113 113 }) { 114 - did ??= user.did; 114 + did ??= user.did ?? undefined; 115 115 if (!collection) { 116 116 throw new Error('Missing parameters for listRecords'); 117 117 } 118 118 if (!did) { 119 - throw new Error('Missing did for getRecord'); 119 + throw new Error('Missing did for listRecords'); 120 120 } 121 121 122 122 client ??= await getClient({ did }); ··· 159 159 rkey?: string; 160 160 client?: Client; 161 161 }) { 162 - did ??= user.did; 162 + did ??= user.did ?? undefined; 163 163 164 164 if (!collection) { 165 165 throw new Error('Missing parameters for getRecord'); ··· 195 195 }) { 196 196 if (!user.did) throw new Error('Not logged in'); 197 197 198 - const { putRecord: putRecordRemote } = await import('./repo.remote'); 198 + const { putRecord: putRecordRemote } = await import('./server/repo.remote'); 199 199 const data = await putRecordRemote({ collection, rkey, record }); 200 200 return { ok: true, data }; 201 201 } ··· 212 212 }) { 213 213 if (!user.did) throw new Error('Not logged in'); 214 214 215 - const { deleteRecord: deleteRecordRemote } = await import('./repo.remote'); 215 + const { deleteRecord: deleteRecordRemote } = await import('./server/repo.remote'); 216 216 const data = await deleteRecordRemote({ collection, rkey }); 217 217 return data.ok; 218 218 } ··· 223 223 export async function uploadBlob({ blob }: { blob: Blob }) { 224 224 if (!user.did) throw new Error("Can't upload blob: Not logged in"); 225 225 226 - const { uploadBlob: uploadBlobRemote } = await import('./repo.remote'); 226 + const { uploadBlob: uploadBlobRemote } = await import('./server/repo.remote'); 227 227 return await uploadBlobRemote({ blob }); 228 228 } 229 229 ··· 231 231 * Gets metadata about a repository. 232 232 */ 233 233 export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { 234 - did ??= user.did; 234 + did ??= user.did ?? undefined; 235 235 if (!did) { 236 236 throw new Error('Error describeRepo: No did'); 237 237 } ··· 281 281 }; 282 282 }; 283 283 }) { 284 - did ??= user.did; 284 + did ??= user.did ?? undefined; 285 285 286 286 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; 287 287 }
+5 -4
src/lib/atproto/oauth.remote.ts src/lib/atproto/server/oauth.remote.ts
··· 1 1 import * as v from 'valibot'; 2 2 import { error } from '@sveltejs/kit'; 3 3 import { command, getRequestEvent } from '$app/server'; 4 - import { createOAuthClient } from '$lib/server/oauth'; 5 - import { scope } from '$lib/atproto/metadata'; 6 - import { signUpPDS } from '$lib/atproto/settings'; 4 + import { createOAuthClient } from './oauth'; 5 + import { getSignedCookie } from './signed-cookie'; 6 + import { scope } from '../metadata'; 7 + import { signUpPDS } from '../settings'; 7 8 import type { ActorIdentifier, Did } from '@atcute/lexicons'; 8 9 9 10 export const oauthLogin = command( ··· 38 39 39 40 export const oauthLogout = command(async () => { 40 41 const { cookies, platform } = getRequestEvent(); 41 - const did = cookies.get('did') as Did | undefined; 42 + const did = getSignedCookie(cookies, 'did') as Did | null; 42 43 43 44 if (did) { 44 45 try {
+13 -4
src/lib/atproto/repo.remote.ts src/lib/atproto/server/repo.remote.ts
··· 1 1 import { error } from '@sveltejs/kit'; 2 2 import { command, getRequestEvent } from '$app/server'; 3 3 import * as v from 'valibot'; 4 + import { permissions } from '../settings'; 4 5 6 + // Validate collection format and check against allowed list from settings 5 7 const collectionSchema = v.pipe( 6 8 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-]*$/) 9 + v.regex(/^[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*$/), 10 + v.check( 11 + (c) => permissions.collections.some((allowed) => c === allowed || allowed.startsWith(c + '?')), 12 + 'Collection not in allowed list' 13 + ) 8 14 ); 9 15 16 + // AT Protocol rkey: TID, 'self', or other valid record keys (alphanumeric, dash, underscore, dot) 17 + const rkeySchema = v.optional(v.pipe(v.string(), v.regex(/^[a-zA-Z0-9._:~-]{1,512}$/))); 18 + 10 19 export const putRecord = command( 11 20 v.object({ 12 21 collection: collectionSchema, 13 - rkey: v.optional(v.string()), 22 + rkey: rkeySchema, 14 23 record: v.record(v.string(), v.unknown()) 15 24 }), 16 25 async (input) => { ··· 33 42 export const deleteRecord = command( 34 43 v.object({ 35 44 collection: collectionSchema, 36 - rkey: v.optional(v.string()) 45 + rkey: rkeySchema 37 46 }), 38 47 async (input) => { 39 48 const { locals } = getRequestEvent(); ··· 64 73 input: input.blob 65 74 }); 66 75 67 - if (!response?.ok) error(500, 'Upload failed'); 76 + if (!response.ok) error(500, 'Upload failed'); 68 77 69 78 return response.data.blob as { 70 79 $type: 'blob';
+4
src/lib/atproto/scripts/generate-key.ts
··· 1 + import { generateClientAssertionKey } from '@atcute/oauth-node-client'; 2 + 3 + const key = await generateClientAssertionKey('main-key'); 4 + console.log(JSON.stringify(key));
+3
src/lib/atproto/scripts/generate-secret.ts
··· 1 + import { randomBytes } from 'node:crypto'; 2 + 3 + console.log(randomBytes(32).toString('base64url'));
+47
src/lib/atproto/scripts/setup-dev.ts
··· 1 + import { existsSync } from 'node:fs'; 2 + import { copyFile, readFile, writeFile } from 'node:fs/promises'; 3 + import { resolve } from 'node:path'; 4 + import { randomBytes } from 'node:crypto'; 5 + 6 + import { generateClientAssertionKey } from '@atcute/oauth-node-client'; 7 + 8 + const cwd = process.cwd(); 9 + const examplePath = resolve(cwd, '.env.example'); 10 + const envPath = resolve(cwd, '.env'); 11 + 12 + if (!existsSync(envPath)) { 13 + if (!existsSync(examplePath)) { 14 + throw new Error(`missing .env.example (expected at ${examplePath})`); 15 + } 16 + await copyFile(examplePath, envPath); 17 + console.log(`created ${envPath}`); 18 + } 19 + 20 + const upsertVar = (input: string, key: string, value: string): string => { 21 + const line = `${key}=${value}`; 22 + const re = new RegExp(`^${key}=.*$`, 'm'); 23 + 24 + if (re.test(input)) { 25 + const match = input.match(re); 26 + const current = match ? match[0].slice(key.length + 1).trim() : ''; 27 + // Only overwrite if empty/placeholder 28 + if (current === '' || current === "''" || current === '""' || current.includes('...')) { 29 + return input.replace(re, line); 30 + } 31 + return input; 32 + } 33 + 34 + const suffix = input.endsWith('\n') || input.length === 0 ? '' : '\n'; 35 + return `${input}${suffix}${line}\n`; 36 + }; 37 + 38 + let vars = await readFile(envPath, 'utf8'); 39 + 40 + const secret = randomBytes(32).toString('base64url'); 41 + vars = upsertVar(vars, 'COOKIE_SECRET', secret); 42 + 43 + const jwk = await generateClientAssertionKey('main-key'); 44 + vars = upsertVar(vars, 'CLIENT_ASSERTION_KEY', JSON.stringify(jwk)); 45 + 46 + await writeFile(envPath, vars); 47 + console.log(`updated ${envPath}`);
+51
src/lib/atproto/server/profile.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import { getDetailedProfile, describeRepo } from '../methods'; 3 + 4 + const PROFILE_CACHE_TTL = 60 * 60; // 1 hour 5 + 6 + /** 7 + * Loads a user's profile, with optional KV caching. 8 + * Falls back to a fresh fetch if the cache KV doesn't exist or on cache miss. 9 + * Returns undefined if the profile can't be loaded. 10 + */ 11 + export async function loadProfile(did: Did, profileCache?: KVNamespace) { 12 + // Try cache first 13 + if (profileCache) { 14 + try { 15 + const cached = await profileCache.get(did, 'json'); 16 + if (cached) return cached as Record<string, unknown>; 17 + } catch { 18 + // Cache read failed, continue to fresh fetch 19 + } 20 + } 21 + 22 + const profile = await fetchProfile(did); 23 + 24 + // Write to cache (fire-and-forget) 25 + if (profileCache && profile) { 26 + profileCache 27 + .put(did, JSON.stringify(profile), { expirationTtl: PROFILE_CACHE_TTL }) 28 + .catch(() => {}); 29 + } 30 + 31 + return profile; 32 + } 33 + 34 + async function fetchProfile(did: Did) { 35 + try { 36 + let profile = await getDetailedProfile({ did }); 37 + 38 + if (!profile || profile.handle === 'handle.invalid') { 39 + const repo = await describeRepo({ did }); 40 + profile = { 41 + did, 42 + handle: repo?.handle || 'handle.invalid' 43 + } as typeof profile; 44 + } 45 + 46 + return profile; 47 + } catch (e) { 48 + console.error('Failed to load profile:', e); 49 + return undefined; 50 + } 51 + }
+43
src/lib/atproto/server/session.ts
··· 1 + import type { Cookies } from '@sveltejs/kit'; 2 + import { Client } from '@atcute/client'; 3 + import type { Did } from '@atcute/lexicons'; 4 + import type { OAuthSession } from '@atcute/oauth-node-client'; 5 + import { createOAuthClient } from './oauth'; 6 + import { getSignedCookie } from './signed-cookie'; 7 + 8 + export type SessionLocals = { 9 + session: OAuthSession | null; 10 + client: Client | null; 11 + did: Did | null; 12 + }; 13 + 14 + /** 15 + * Restores an OAuth session from the signed `did` cookie. 16 + * Returns session locals to be assigned to `event.locals`. 17 + * Deletes the cookie if the session can't be restored. 18 + */ 19 + export async function restoreSession( 20 + cookies: Cookies, 21 + env?: App.Platform['env'] 22 + ): Promise<SessionLocals> { 23 + const did = getSignedCookie(cookies, 'did') as Did | null; 24 + 25 + if (!did) { 26 + return { session: null, client: null, did: null }; 27 + } 28 + 29 + try { 30 + const oauth = createOAuthClient(env); 31 + const session = await oauth.restore(did); 32 + 33 + return { 34 + session, 35 + client: new Client({ handler: session }), 36 + did 37 + }; 38 + } catch (e) { 39 + console.error('Failed to restore session:', e); 40 + cookies.delete('did', { path: '/' }); 41 + return { session: null, client: null, did: null }; 42 + } 43 + }
+69
src/lib/atproto/server/signed-cookie.ts
··· 1 + import { createHmac, timingSafeEqual } from 'node:crypto'; 2 + 3 + import type { Cookies } from '@sveltejs/kit'; 4 + 5 + import { env } from '$env/dynamic/private'; 6 + import { dev } from '$app/environment'; 7 + 8 + const SEPARATOR = '.'; 9 + 10 + function getSecret(): string { 11 + const secret = env.COOKIE_SECRET; 12 + if (secret) return secret; 13 + if (dev) return 'dev-cookie-secret-not-for-production'; 14 + throw new Error('COOKIE_SECRET is not set'); 15 + } 16 + 17 + function toBase64Url(bytes: Uint8Array): string { 18 + let binary = ''; 19 + for (const byte of bytes) binary += String.fromCharCode(byte); 20 + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 21 + } 22 + 23 + function fromBase64Url(str: string): Uint8Array { 24 + const padded = str + '='.repeat((4 - (str.length % 4)) % 4); 25 + const base64 = padded.replace(/-/g, '+').replace(/_/g, '/'); 26 + const binary = atob(base64); 27 + const bytes = new Uint8Array(binary.length); 28 + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); 29 + return bytes; 30 + } 31 + 32 + function hmacSha256(data: string): Uint8Array { 33 + return createHmac('sha256', getSecret()).update(data).digest(); 34 + } 35 + 36 + export function getSignedCookie(cookies: Cookies, name: string): string | null { 37 + const signed = cookies.get(name); 38 + if (!signed) return null; 39 + 40 + const idx = signed.lastIndexOf(SEPARATOR); 41 + if (idx === -1) return null; 42 + 43 + const value = signed.slice(0, idx); 44 + const sig = signed.slice(idx + 1); 45 + 46 + let expected: Uint8Array; 47 + let got: Uint8Array; 48 + try { 49 + expected = hmacSha256(value); 50 + got = fromBase64Url(sig); 51 + } catch { 52 + return null; 53 + } 54 + 55 + if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null; 56 + 57 + return value; 58 + } 59 + 60 + export function setSignedCookie( 61 + cookies: Cookies, 62 + name: string, 63 + value: string, 64 + options: Parameters<Cookies['set']>[2] 65 + ): void { 66 + const sig = toBase64Url(hmacSha256(value)); 67 + const signed = `${value}${SEPARATOR}${sig}`; 68 + cookies.set(name, signed, options); 69 + }
+1 -5
src/lib/atproto/settings.ts
··· 1 1 import { dev } from '$app/environment'; 2 2 3 - export const SITE = dev 4 - ? 'http://localhost:5183' 5 - : 'https://svelte-atproto-oauth-cloudflare-workers.flobit-dev.workers.dev'; 6 - 7 3 type Permissions = { 8 4 collections: readonly string[]; 9 5 rpc: Record<string, string | string[]>; ··· 26 22 27 23 export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>; 28 24 29 - // which PDS to use for signup 25 + // which PDS to use for signup (change to your preferred PDS) 30 26 const devPDS = 'https://pds.rip/'; 31 27 const prodPDS = 'https://selfhosted.social/'; 32 28 export const signUpPDS = dev ? devPDS : prodPDS;
src/lib/server/kv-store.ts src/lib/atproto/server/kv-store.ts
+14 -10
src/lib/server/oauth.ts src/lib/atproto/server/oauth.ts
··· 18 18 WellKnownHandleResolver 19 19 } from '@atcute/identity-resolver'; 20 20 import { KVStore } from './kv-store'; 21 - import { DOH_RESOLVER, REDIRECT_PATH, SITE } from '$lib/atproto/settings'; 22 - import { scope } from '$lib/atproto/metadata'; 21 + import { DOH_RESOLVER, REDIRECT_PATH } from '../settings'; 22 + import { scope } from '../metadata'; 23 23 import { dev } from '$app/environment'; 24 24 25 25 function createActorResolver() { ··· 57 57 const actorResolver = createActorResolver(); 58 58 const stores = createStores(env); 59 59 60 - if (dev) { 61 - // In development, use loopback client (public, no keyset). 60 + if (dev && !env?.OAUTH_PUBLIC_URL) { 61 + // Dev without tunnel: loopback public client (no keyset). 62 62 // Omit client_id — the library builds it automatically from redirect_uris + scope. 63 63 // redirect_uris must use 127.0.0.1 (not localhost). 64 64 return new OAuthClient({ ··· 71 71 }); 72 72 } 73 73 74 - // In production, use confidential client with keyset 75 - if (!env?.CLIENT_ASSERTION_KEY) { 76 - throw new Error('CLIENT_ASSERTION_KEY secret is not set. Run: pnpm generate-key && npx wrangler secret put CLIENT_ASSERTION_KEY'); 74 + // Confidential client (production, or dev with tunnel via OAUTH_PUBLIC_URL) 75 + if (!env?.OAUTH_PUBLIC_URL) { 76 + throw new Error('OAUTH_PUBLIC_URL is not set'); 77 77 } 78 + if (!env.CLIENT_ASSERTION_KEY) { 79 + throw new Error('CLIENT_ASSERTION_KEY secret is not set. Run: pnpm env:generate-key'); 80 + } 81 + const site = env.OAUTH_PUBLIC_URL; 78 82 const key: ClientAssertionPrivateJwk = JSON.parse(env.CLIENT_ASSERTION_KEY); 79 83 80 84 return new OAuthClient({ 81 85 metadata: { 82 - client_id: SITE + '/oauth-client-metadata.json', 83 - redirect_uris: [SITE + REDIRECT_PATH], 86 + client_id: site + '/oauth-client-metadata.json', 87 + redirect_uris: [site + REDIRECT_PATH], 84 88 scope, 85 - jwks_uri: SITE + '/oauth/jwks.json' 89 + jwks_uri: site + '/oauth/jwks.json' 86 90 }, 87 91 keyset: [key], 88 92 actorResolver,
+28
src/routes/(oauth)/oauth/callback/+server.ts
··· 1 + import { redirect } from '@sveltejs/kit'; 2 + import { createOAuthClient } from '$lib/atproto/server/oauth'; 3 + import { setSignedCookie } from '$lib/atproto/server/signed-cookie'; 4 + import { dev } from '$app/environment'; 5 + import type { RequestHandler } from './$types'; 6 + 7 + export const GET: RequestHandler = async ({ url, platform, cookies }) => { 8 + const oauth = createOAuthClient(platform?.env); 9 + 10 + // oauth.callback() validates the state parameter (CSRF protection) and 11 + // exchanges the authorization code for tokens via the token endpoint. 12 + try { 13 + const { session } = await oauth.callback(url.searchParams); 14 + 15 + setSignedCookie(cookies, 'did', session.did, { 16 + path: '/', 17 + httpOnly: true, 18 + secure: !dev, 19 + sameSite: 'lax', 20 + maxAge: 60 * 60 * 24 * 180 // 180 days 21 + }); 22 + } catch (e) { 23 + console.error('OAuth callback failed:', e); 24 + redirect(303, '/?error=auth_failed'); 25 + } 26 + 27 + redirect(303, '/'); 28 + };
+4 -17
src/routes/+layout.server.ts
··· 1 1 import type { LayoutServerLoad } from './$types'; 2 - import { getDetailedProfile, describeRepo } from '$lib/atproto/methods'; 2 + import { loadProfile } from '$lib/atproto/server/profile'; 3 3 4 - export const load: LayoutServerLoad = async ({ locals }) => { 4 + export const load: LayoutServerLoad = async ({ locals, platform }) => { 5 5 if (!locals.did || !locals.client) { 6 - return { did: undefined, profile: undefined }; 6 + return { did: null, profile: null }; 7 7 } 8 8 9 - let profile; 10 - try { 11 - profile = await getDetailedProfile({ did: locals.did }); 12 - 13 - if (!profile || profile.handle === 'handle.invalid') { 14 - const repo = await describeRepo({ did: locals.did }); 15 - profile = { 16 - did: locals.did, 17 - handle: repo?.handle || 'handle.invalid' 18 - }; 19 - } 20 - } catch (e) { 21 - console.error('Failed to load profile:', e); 22 - } 9 + const profile = await loadProfile(locals.did, platform?.env?.PROFILE_CACHE); 23 10 24 11 return { 25 12 did: locals.did,
+12 -3
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 - 4 - import LoginModal from '$lib/atproto/UI/LoginModal.svelte'; 3 + import { AtprotoLoginModal } from '@foxui/social'; 4 + import { login, signup } from '$lib/atproto'; 5 5 6 6 let { children } = $props(); 7 7 </script> 8 8 9 9 {@render children()} 10 10 11 - <LoginModal /> 11 + <AtprotoLoginModal 12 + login={async (handle) => { 13 + await login(handle); 14 + return true; 15 + }} 16 + signup={async () => { 17 + signup(); 18 + return true; 19 + }} 20 + />
+38 -33
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { invalidateAll } from '$app/navigation'; 3 + import { flip } from 'svelte/animate'; 4 + import { scale } from 'svelte/transition'; 3 5 import { user, logout } from '$lib/atproto'; 4 - import Avatar from '$lib/atproto/UI/Avatar.svelte'; 5 - import Button from '$lib/atproto/UI/Button.svelte'; 6 - import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 7 - import { createTID } from '$lib/atproto/methods'; 8 - import { putRecord } from '$lib/atproto/repo.remote'; 6 + import { Button, Avatar } from '@foxui/core'; 7 + import { atProtoLoginModalState, EmojiPicker } from '@foxui/social'; 8 + import { RelativeTime } from '@foxui/time'; 9 9 10 - let emojis = ['😅', '🫶', '🤗', '🙃', '😊', '🤔']; 10 + import { createTID } from '$lib/atproto/methods'; 11 + import { putRecord } from '$lib/atproto/server/repo.remote'; 11 12 12 13 let { data } = $props(); 13 14 </script> ··· 23 24 24 25 {#if !user.isLoggedIn} 25 26 <div class="mt-8 text-sm">not logged in</div> 26 - <Button class="mt-4" onclick={() => loginModalState.show()}>Login</Button> 27 + <Button class="mt-4" onclick={() => atProtoLoginModalState.show()}>Login</Button> 27 28 {/if} 28 29 29 30 {#if user.isLoggedIn} ··· 36 37 37 38 <div class="my-4 text-sm"> 38 39 Statusphere test: 39 - <div class="mt-2 flex flex-wrap gap-2"> 40 - {#each emojis as emoji (emoji)} 41 - <button 42 - class="bg-base-100 dark:bg-base-950 cursor-pointer rounded-xl p-1 px-2" 43 - onclick={async () => { 44 - await putRecord({ 45 - rkey: createTID(), 46 - collection: 'xyz.statusphere.status', 47 - record: { 48 - status: emoji, 49 - createdAt: new Date() 50 - } 51 - }); 52 - await invalidateAll(); 53 - }}>{emoji}</button 54 - > 55 - {/each} 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} 40 + <EmojiPicker 41 + onpicked={async (emoji) => { 42 + await putRecord({ 43 + rkey: createTID(), 44 + collection: 'xyz.statusphere.status', 45 + record: { 46 + status: emoji.unicode, 47 + createdAt: new Date() 48 + } 49 + }); 50 + await invalidateAll(); 51 + }} 52 + /> 53 + {#if data.statuses.length > 0} 54 + <div class="mt-4 text-sm">Recent statuses:</div> 55 + <ul class="mt-2"> 56 + {#each data.statuses as status, i (status.rkey)} 57 + <li class="flex items-center gap-2 py-1" animate:flip={{ duration: 300 }}> 58 + {#if i === 0} 59 + <span class="text-2xl" in:scale={{ duration: 300 }}>{status.status}</span> 60 + {:else} 61 + <span class="text-2xl">{status.status}</span> 62 + {/if} 63 + <span class="text-base-400 dark:text-base-500 text-sm"> 64 + <RelativeTime date={new Date(status.createdAt)} locale="en-US" /> 65 + </span> 66 + </li> 67 + {/each} 68 + </ul> 69 + {/if} 65 70 </div> 66 71 67 72 <Button class="mt-4" onclick={() => logout()}>Sign Out</Button>
+1 -1
src/routes/oauth-client-metadata.json/+server.ts src/routes/(oauth)/oauth-client-metadata.json/+server.ts
··· 1 1 import { json } from '@sveltejs/kit'; 2 - import { createOAuthClient } from '$lib/server/oauth'; 2 + import { createOAuthClient } from '$lib/atproto/server/oauth'; 3 3 import type { RequestHandler } from './$types'; 4 4 5 5 export const GET: RequestHandler = async ({ platform }) => {
-20
src/routes/oauth/callback/+server.ts
··· 1 - import { redirect } from '@sveltejs/kit'; 2 - import { createOAuthClient } from '$lib/server/oauth'; 3 - import { dev } from '$app/environment'; 4 - import type { RequestHandler } from './$types'; 5 - 6 - export const GET: RequestHandler = async ({ url, platform, cookies }) => { 7 - const oauth = createOAuthClient(platform?.env); 8 - 9 - const { session } = await oauth.callback(url.searchParams); 10 - 11 - cookies.set('did', session.did, { 12 - path: '/', 13 - httpOnly: true, 14 - secure: !dev, 15 - sameSite: 'lax', 16 - maxAge: 60 * 60 * 24 * 180 // 180 days 17 - }); 18 - 19 - redirect(303, '/'); 20 - };
+1 -1
src/routes/oauth/jwks.json/+server.ts src/routes/(oauth)/oauth/jwks.json/+server.ts
··· 1 1 import { json } from '@sveltejs/kit'; 2 - import { createOAuthClient } from '$lib/server/oauth'; 2 + import { createOAuthClient } from '$lib/atproto/server/oauth'; 3 3 import type { RequestHandler } from './$types'; 4 4 5 5 export const GET: RequestHandler = async ({ platform }) => {
+4 -29
wrangler.jsonc
··· 1 - /** 2 - * For more details on how to configure Wrangler, refer to: 3 - * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 - */ 5 1 { 6 2 "$schema": "node_modules/wrangler/config-schema.json", 7 3 "name": "svelte-atproto-oauth-cloudflare-workers", 8 4 "main": ".svelte-kit/cloudflare/_worker.js", 9 5 "compatibility_date": "2025-12-25", 10 - "compatibility_flags": ["nodejs_als"], 6 + "compatibility_flags": ["nodejs_compat_v2"], 11 7 "assets": { 12 8 "binding": "ASSETS", 13 9 "directory": ".svelte-kit/cloudflare" ··· 15 11 "observability": { 16 12 "enabled": true 17 13 }, 18 - /** 19 - * Smart Placement 20 - * https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement 21 - */ 22 - // "placement": { "mode": "smart" } 23 - /** 24 - * Bindings 25 - * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including 26 - * databases, object storage, AI inference, real-time communication and more. 27 - * https://developers.cloudflare.com/workers/runtime-apis/bindings/ 28 - */ 29 - /** 30 - * Environment Variables 31 - * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables 32 - * Note: Use secrets to store sensitive data. 33 - * https://developers.cloudflare.com/workers/configuration/secrets/ 34 - */ 35 - "vars": {}, 14 + "vars": { 15 + "OAUTH_PUBLIC_URL": "https://svelte-atproto-oauth-cloudflare-workers.flobit-dev.workers.dev" 16 + }, 36 17 "kv_namespaces": [ 37 18 { 38 19 "binding": "OAUTH_SESSIONS", ··· 43 24 "id": "115d8089d9ca4592a3a5e4a7d9146610" 44 25 } 45 26 ] 46 - 47 - /** 48 - * Service Bindings (communicate between multiple Workers) 49 - * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings 50 - */ 51 - // "services": [ { "binding": "MY_SERVICE", "service": "my-service" } ] 52 27 }