diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c7bf6e5..6703ebd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,9 @@ "WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:github.com)", "mcp__plugin_svelte_svelte__svelte-autofixer", - "mcp__plugin_svelte_svelte__get-documentation" + "mcp__plugin_svelte_svelte__get-documentation", + "Bash(pnpm check:*)", + "Bash(pnpm env:generate-secret:*)" ] } } diff --git a/.dev.vars.example b/.dev.vars.example deleted file mode 100644 index c9e6b73..0000000 --- a/.dev.vars.example +++ /dev/null @@ -1 +0,0 @@ -CLIENT_ASSERTION_KEY={"kty":"EC","crv":"P-256","x":"...","y":"...","d":"...","kid":"main-key","alg":"ES256"} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ede6fba --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Generate both with: pnpm env:setup-dev +CLIENT_ASSERTION_KEY= +COOKIE_SECRET= + +# Set to your tunnel URL to use a confidential client in dev +# OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com diff --git a/.gitignore b/.gitignore index 2ed5c18..3b462cb 100644 --- a/.gitignore +++ b/.gitignore @@ -15,10 +15,8 @@ Thumbs.db # Env .env .env.* -.dev.vars !.env.example !.env.test -!.dev.vars.example # Vite vite.config.js.timestamp-* diff --git a/AGENT_SETUP.md b/AGENT_SETUP.md new file mode 100644 index 0000000..9dc8934 --- /dev/null +++ b/AGENT_SETUP.md @@ -0,0 +1,1373 @@ +# Add AT Protocol OAuth to SvelteKit + Cloudflare Workers + +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. + +## Prerequisites + +The project must already use: +- SvelteKit with `@sveltejs/adapter-cloudflare` +- A `wrangler.jsonc` (or `wrangler.toml`) config + +## Step 0: Ask the user + +Before making any changes, ask the user these questions: + +1. **UI**: Should I add a login UI? + - **`foxui`** — Use `@foxui/social` login modal (polished, recommended) + - **`basic`** — Simple login/logout page at `/user` route (uses Tailwind if available) + - **`none`** — Backend only, no UI (you'll build your own) + +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. + +3. **Blobs**: Does the app need to upload blobs (images, video)? If yes, what types? (e.g. `image/*`, `video/*`) + +Use the answers to customize `settings.ts` (marked with `CUSTOMIZE` below) and choose which UI dependencies/files to create. + +## Step 1: Install dependencies + +Always install: + +```sh +pnpm add valibot +pnpm add -D @atcute/oauth-node-client @atcute/identity-resolver @atcute/lexicons @atcute/client @atcute/tid @cloudflare/workers-types tsx @atcute/atproto @atcute/bluesky +``` + +If UI choice is `foxui`: + +```sh +pnpm add @foxui/social @foxui/core +``` + +## Step 2: Create files + +Create all of the following files. These go into `src/lib/atproto/` and `src/routes/(oauth)/`. + +### `src/lib/atproto/settings.ts` + +Fill in `collections` and `blobs` from the user's answers. If no collections were specified, use an empty array. + +```ts +import { dev } from '$app/environment'; + +type Permissions = { + collections: readonly string[]; + rpc: Record; + blobs: readonly string[]; +}; + +export const permissions = { + // CUSTOMIZE: add the user's collections + collections: [], + + // CUSTOMIZE: add any authenticated RPC requests needed + rpc: {}, + + // CUSTOMIZE: add blob types if the user needs uploads (e.g. ['image/*']) + blobs: [] +} as const satisfies Permissions; + +type ExtractCollectionBase = T extends `${infer Base}?${string}` ? Base : T; + +export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>; + +// PDS to use for signup (change to preferred PDS) +const devPDS = 'https://bsky.social/'; +const prodPDS = 'https://bsky.social/'; +export const signUpPDS = dev ? devPDS : prodPDS; + +export const REDIRECT_PATH = '/oauth/callback'; + +export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query'; +``` + +### `src/lib/atproto/metadata.ts` + +```ts +import { permissions } from './settings'; + +function constructScope() { + const parts: string[] = ['atproto']; + + for (const collection of permissions.collections) { + parts.push('repo:' + collection); + } + + for (const [key, value] of Object.entries(permissions.rpc ?? {})) { + const lxms = Array.isArray(value) ? value : [value]; + for (const lxm of lxms) { + parts.push('rpc?lxm=' + lxm + '&aud=' + key); + } + } + + if (permissions.blobs.length > 0) { + parts.push('blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&')); + } + + return parts.join(' '); +} + +export const scope = constructScope(); +``` + +### `src/lib/atproto/auth.svelte.ts` + +```ts +import { AppBskyActorDefs } from '@atcute/bluesky'; +import type { ActorIdentifier, Did } from '@atcute/lexicons'; +import { page } from '$app/state'; + +export const user = { + get profile() { + return (page.data?.profile as AppBskyActorDefs.ProfileViewDetailed | null) ?? null; + }, + get isLoggedIn() { + return !!page.data?.did; + }, + get did() { + return (page.data?.did as Did | null) ?? null; + } +}; + +export async function login(handle: string) { + if (handle.startsWith('did:')) { + if (handle.length < 6) throw new Error('DID must be at least 6 characters'); + } else if (handle.includes('.') && handle.length > 3) { + handle = (handle.startsWith('@') ? handle.slice(1) : handle) as ActorIdentifier; + if (handle.length < 4) throw new Error('Handle must be at least 4 characters'); + } else if (handle.length > 3) { + handle = ((handle.startsWith('@') ? handle.slice(1) : handle) + + '.bsky.social') as ActorIdentifier; + } else { + throw new Error('Please provide a valid handle or DID.'); + } + + const { oauthLogin } = await import('./server/oauth.remote'); + const { url } = await oauthLogin({ handle }); + window.location.assign(url); + + await new Promise((_resolve, reject) => { + window.addEventListener('pageshow', () => reject(new Error('user aborted the login request')), { + once: true + }); + }); +} + +export async function signup() { + const { oauthLogin } = await import('./server/oauth.remote'); + const { url } = await oauthLogin({ signup: true }); + window.location.assign(url); + + await new Promise((_resolve, reject) => { + window.addEventListener('pageshow', () => reject(new Error('user aborted the signup request')), { + once: true + }); + }); +} + +export async function logout() { + try { + const { oauthLogout } = await import('./server/oauth.remote'); + await oauthLogout(); + } catch (e) { + console.error('Error logging out:', e); + } + + window.location.href = '/'; +} +``` + +### `src/lib/atproto/methods.ts` + +```ts +import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons'; +import { user } from './auth.svelte'; +import { DOH_RESOLVER, type AllowedCollection } from './settings'; +import { + CompositeDidDocumentResolver, + CompositeHandleResolver, + DohJsonHandleResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, + WellKnownHandleResolver +} from '@atcute/identity-resolver'; +import { Client, simpleFetchHandler } from '@atcute/client'; +import { type AppBskyActorDefs } from '@atcute/bluesky'; + +export type Collection = `${string}.${string}.${string}`; +import * as TID from '@atcute/tid'; + +export function parseUri(uri: string) { + const parts = parseResourceUri(uri); + if (!parts.ok) return; + return parts.value; +} + +export async function resolveHandle({ handle }: { handle: Handle }) { + const handleResolver = new CompositeHandleResolver({ + methods: { + dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }), + http: new WellKnownHandleResolver() + } + }); + + const data = await handleResolver.resolve(handle); + return data; +} + +const didResolver = new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver() + } +}); + +export async function getPDS(did: Did) { + const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>); + if (!doc.service) throw new Error('No PDS found'); + for (const service of doc.service) { + if (service.id === '#atproto_pds') { + return service.serviceEndpoint.toString(); + } + } +} + +export async function getDetailedProfile(data?: { did?: Did; client?: Client }) { + data ??= {}; + data.did ??= user.did ?? undefined; + + if (!data.did) throw new Error('Error getting detailed profile: no did'); + + data.client ??= new Client({ + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) + }); + + const response = await data.client.get('app.bsky.actor.getProfile', { + params: { actor: data.did } + }); + + if (!response.ok) return; + + return response.data; +} + +export async function getClient({ did }: { did: Did }) { + const pds = await getPDS(did); + if (!pds) throw new Error('PDS not found'); + + const client = new Client({ + handler: simpleFetchHandler({ service: pds }) + }); + + return client; +} + +export async function listRecords({ + did, + collection, + cursor, + limit = 100, + client +}: { + did?: Did; + collection: `${string}.${string}.${string}`; + cursor?: string; + limit?: number; + client?: Client; +}) { + did ??= user.did ?? undefined; + if (!collection) { + throw new Error('Missing parameters for listRecords'); + } + if (!did) { + throw new Error('Missing did for listRecords'); + } + + client ??= await getClient({ did }); + + const allRecords = []; + + let currentCursor = cursor; + do { + const response = await client.get('com.atproto.repo.listRecords', { + params: { + repo: did, + collection, + limit: !limit || limit > 100 ? 100 : limit, + cursor: currentCursor + } + }); + + if (!response.ok) { + return allRecords; + } + + allRecords.push(...response.data.records); + currentCursor = response.data.cursor; + } while (currentCursor && (!limit || allRecords.length < limit)); + + return allRecords; +} + +export async function getRecord({ + did, + collection, + rkey = 'self', + client +}: { + did?: Did; + collection: Collection; + rkey?: string; + client?: Client; +}) { + did ??= user.did ?? undefined; + + if (!collection) { + throw new Error('Missing parameters for getRecord'); + } + if (!did) { + throw new Error('Missing did for getRecord'); + } + + client ??= await getClient({ did }); + + const record = await client.get('com.atproto.repo.getRecord', { + params: { + repo: did, + collection, + rkey + } + }); + + return JSON.parse(JSON.stringify(record.data)); +} + +export async function putRecord({ + collection, + rkey = 'self', + record +}: { + collection: AllowedCollection; + rkey?: string; + record: Record; +}) { + if (!user.did) throw new Error('Not logged in'); + + const { putRecord: putRecordRemote } = await import('./server/repo.remote'); + const data = await putRecordRemote({ collection, rkey, record }); + return { ok: true, data }; +} + +export async function deleteRecord({ + collection, + rkey = 'self' +}: { + collection: AllowedCollection; + rkey: string; +}) { + if (!user.did) throw new Error('Not logged in'); + + const { deleteRecord: deleteRecordRemote } = await import('./server/repo.remote'); + const data = await deleteRecordRemote({ collection, rkey }); + return data.ok; +} + +export async function uploadBlob({ blob }: { blob: Blob }) { + if (!user.did) throw new Error("Can't upload blob: Not logged in"); + + const { uploadBlob: uploadBlobRemote } = await import('./server/repo.remote'); + return await uploadBlobRemote({ blob }); +} + +export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { + did ??= user.did ?? undefined; + if (!did) { + throw new Error('Error describeRepo: No did'); + } + client ??= await getClient({ did }); + + const repo = await client.get('com.atproto.repo.describeRepo', { + params: { + repo: did + } + }); + if (!repo.ok) return; + + return repo.data; +} + +export async function getBlobURL({ + did, + blob +}: { + did: Did; + blob: { + $type: 'blob'; + ref: { + $link: string; + }; + }; +}) { + const pds = await getPDS(did); + return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; +} + +export function getCDNImageBlobUrl({ + did, + blob +}: { + did?: string; + blob: { + $type: 'blob'; + ref: { + $link: string; + }; + }; +}) { + did ??= user.did ?? undefined; + + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; +} + +export async function searchActorsTypeahead( + q: string, + limit: number = 10, + host?: string +): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> { + host ??= 'https://public.api.bsky.app'; + + const client = new Client({ + handler: simpleFetchHandler({ service: host }) + }); + + const response = await client.get('app.bsky.actor.searchActorsTypeahead', { + params: { + q, + limit + } + }); + + if (!response.ok) return { actors: [], q }; + + return { actors: response.data.actors, q }; +} + +export function createTID() { + return TID.now(); +} +``` + +### `src/lib/atproto/index.ts` + +```ts +export { user, login, signup, logout } from './auth.svelte'; + +export { + parseUri, + resolveHandle, + getPDS, + getDetailedProfile, + getClient, + listRecords, + getRecord, + putRecord, + deleteRecord, + uploadBlob, + describeRepo, + getBlobURL, + getCDNImageBlobUrl, + searchActorsTypeahead, + createTID +} from './methods'; +``` + +### `src/lib/atproto/server/signed-cookie.ts` + +```ts +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import type { Cookies } from '@sveltejs/kit'; + +import { env } from '$env/dynamic/private'; +import { dev } from '$app/environment'; + +const SEPARATOR = '.'; + +function getSecret(): string { + const secret = env.COOKIE_SECRET; + if (secret) return secret; + if (dev) return 'dev-cookie-secret-not-for-production'; + throw new Error('COOKIE_SECRET is not set'); +} + +function toBase64Url(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function fromBase64Url(str: string): Uint8Array { + const padded = str + '='.repeat((4 - (str.length % 4)) % 4); + const base64 = padded.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +function hmacSha256(data: string): Uint8Array { + return createHmac('sha256', getSecret()).update(data).digest(); +} + +export function getSignedCookie(cookies: Cookies, name: string): string | null { + const signed = cookies.get(name); + if (!signed) return null; + + const idx = signed.lastIndexOf(SEPARATOR); + if (idx === -1) return null; + + const value = signed.slice(0, idx); + const sig = signed.slice(idx + 1); + + let expected: Uint8Array; + let got: Uint8Array; + try { + expected = hmacSha256(value); + got = fromBase64Url(sig); + } catch { + return null; + } + + if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null; + + return value; +} + +export function setSignedCookie( + cookies: Cookies, + name: string, + value: string, + options: Parameters[2] +): void { + const sig = toBase64Url(hmacSha256(value)); + const signed = `${value}${SEPARATOR}${sig}`; + cookies.set(name, signed, options); +} +``` + +### `src/lib/atproto/server/kv-store.ts` + +```ts +import type { Store } from '@atcute/oauth-node-client'; + +export class KVStore implements Store { + private kv: KVNamespace; + private expirationTtl?: number; + + constructor(kv: KVNamespace, options?: { expirationTtl?: number }) { + this.kv = kv; + this.expirationTtl = options?.expirationTtl; + } + + async get(key: K): Promise { + const value = await this.kv.get(key, 'text'); + if (value === null) return undefined; + return JSON.parse(value) as V; + } + + async set(key: K, value: V): Promise { + await this.kv.put(key, JSON.stringify(value), { + expirationTtl: this.expirationTtl + }); + } + + async delete(key: K): Promise { + await this.kv.delete(key); + } + + async clear(): Promise { + let cursor: string | undefined; + do { + const result = await this.kv.list({ cursor }); + for (const key of result.keys) { + await this.kv.delete(key.name); + } + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); + } +} +``` + +### `src/lib/atproto/server/oauth.ts` + +```ts +import { + OAuthClient, + MemoryStore, + type ClientAssertionPrivateJwk, + type OAuthClientStores, + type OAuthSession, + type StoredSession, + type StoredState +} from '@atcute/oauth-node-client'; +import type { Did } from '@atcute/lexicons'; +import { + CompositeDidDocumentResolver, + CompositeHandleResolver, + DohJsonHandleResolver, + LocalActorResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, + WellKnownHandleResolver +} from '@atcute/identity-resolver'; +import { KVStore } from './kv-store'; +import { DOH_RESOLVER, REDIRECT_PATH } from '../settings'; +import { scope } from '../metadata'; +import { dev } from '$app/environment'; + +function createActorResolver() { + return new LocalActorResolver({ + handleResolver: new CompositeHandleResolver({ + methods: { + dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }), + http: new WellKnownHandleResolver() + } + }), + didDocumentResolver: new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver() + } + }) + }); +} + +function createStores(env?: App.Platform['env']): OAuthClientStores { + if (env?.OAUTH_SESSIONS && env?.OAUTH_STATES) { + return { + sessions: new KVStore(env.OAUTH_SESSIONS), + states: new KVStore(env.OAUTH_STATES, { expirationTtl: 600 }) + }; + } + return { + sessions: new MemoryStore(), + states: new MemoryStore({ ttl: 600_000 }) + }; +} + +export function createOAuthClient(env?: App.Platform['env']): OAuthClient { + const actorResolver = createActorResolver(); + const stores = createStores(env); + + if (dev && !env?.OAUTH_PUBLIC_URL) { + return new OAuthClient({ + metadata: { + redirect_uris: [`http://127.0.0.1:5183${REDIRECT_PATH}`], + scope + }, + actorResolver, + stores + }); + } + + if (!env?.OAUTH_PUBLIC_URL) { + throw new Error('OAUTH_PUBLIC_URL is not set'); + } + if (!env.CLIENT_ASSERTION_KEY) { + throw new Error('CLIENT_ASSERTION_KEY secret is not set. Run: pnpm env:generate-key'); + } + const site = env.OAUTH_PUBLIC_URL; + const key: ClientAssertionPrivateJwk = JSON.parse(env.CLIENT_ASSERTION_KEY); + + return new OAuthClient({ + metadata: { + client_id: site + '/oauth-client-metadata.json', + redirect_uris: [site + REDIRECT_PATH], + scope, + jwks_uri: site + '/oauth/jwks.json' + }, + keyset: [key], + actorResolver, + stores + }); +} + +export type { OAuthSession }; +``` + +### `src/lib/atproto/server/oauth.remote.ts` + +```ts +import * as v from 'valibot'; +import { error } from '@sveltejs/kit'; +import { command, getRequestEvent } from '$app/server'; +import { createOAuthClient } from './oauth'; +import { getSignedCookie } from './signed-cookie'; +import { scope } from '../metadata'; +import { signUpPDS } from '../settings'; +import type { ActorIdentifier, Did } from '@atcute/lexicons'; + +export const oauthLogin = command( + v.object({ + handle: v.optional(v.pipe(v.string(), v.minLength(3))), + signup: v.optional(v.boolean()) + }), + async (input) => { + const { platform } = getRequestEvent(); + + try { + const oauth = createOAuthClient(platform?.env); + + const target = input.signup + ? ({ type: 'pds', serviceUrl: signUpPDS } as const) + : ({ type: 'account', identifier: input.handle as ActorIdentifier } as const); + + const { url } = await oauth.authorize({ + target, + scope, + prompt: input.signup ? 'create' : undefined + }); + + return { url: url.toString() }; + } catch (e) { + if (e && typeof e === 'object' && 'status' in e) throw e; + const message = e instanceof Error ? e.message : 'Login failed'; + error(400, message); + } + } +); + +export const oauthLogout = command(async () => { + const { cookies, platform } = getRequestEvent(); + const did = getSignedCookie(cookies, 'did') as Did | null; + + if (did) { + try { + const oauth = createOAuthClient(platform?.env); + await oauth.revoke(did); + } catch (e) { + console.error('Error revoking session:', e); + } + } + + cookies.delete('did', { path: '/' }); + + return { ok: true }; +}); +``` + +### `src/lib/atproto/server/repo.remote.ts` + +```ts +import { error } from '@sveltejs/kit'; +import { command, getRequestEvent } from '$app/server'; +import * as v from 'valibot'; +import { permissions } from '../settings'; + +const collectionSchema = v.pipe( + v.string(), + v.regex(/^[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*$/), + v.check( + (c) => permissions.collections.some((allowed) => c === allowed || allowed.startsWith(c + '?')), + 'Collection not in allowed list' + ) +); + +const rkeySchema = v.optional(v.pipe(v.string(), v.regex(/^[a-zA-Z0-9._:~-]{1,512}$/))); + +export const putRecord = command( + v.object({ + collection: collectionSchema, + rkey: rkeySchema, + record: v.record(v.string(), v.unknown()) + }), + async (input) => { + const { locals } = getRequestEvent(); + if (!locals.client || !locals.did) error(401, 'Not authenticated'); + + const response = await locals.client.post('com.atproto.repo.putRecord', { + input: { + collection: input.collection as `${string}.${string}.${string}`, + repo: locals.did, + rkey: input.rkey || 'self', + record: input.record + } + }); + + return response.data; + } +); + +export const deleteRecord = command( + v.object({ + collection: collectionSchema, + rkey: rkeySchema + }), + async (input) => { + const { locals } = getRequestEvent(); + if (!locals.client || !locals.did) error(401, 'Not authenticated'); + + const response = await locals.client.post('com.atproto.repo.deleteRecord', { + input: { + collection: input.collection as `${string}.${string}.${string}`, + repo: locals.did, + rkey: input.rkey || 'self' + } + }); + + return { ok: response.ok }; + } +); + +export const uploadBlob = command( + v.object({ + blob: v.instance(Blob) + }), + async (input) => { + const { locals } = getRequestEvent(); + if (!locals.client || !locals.did) error(401, 'Not authenticated'); + + const response = await locals.client.post('com.atproto.repo.uploadBlob', { + params: { repo: locals.did }, + input: input.blob + }); + + if (!response.ok) error(500, 'Upload failed'); + + return response.data.blob as { + $type: 'blob'; + ref: { $link: string }; + mimeType: string; + size: number; + }; + } +); +``` + +### `src/lib/atproto/server/session.ts` + +```ts +import type { Cookies } from '@sveltejs/kit'; +import { Client } from '@atcute/client'; +import type { Did } from '@atcute/lexicons'; +import type { OAuthSession } from '@atcute/oauth-node-client'; +import { createOAuthClient } from './oauth'; +import { getSignedCookie } from './signed-cookie'; + +export type SessionLocals = { + session: OAuthSession | null; + client: Client | null; + did: Did | null; +}; + +export async function restoreSession( + cookies: Cookies, + env?: App.Platform['env'] +): Promise { + const did = getSignedCookie(cookies, 'did') as Did | null; + + if (!did) { + return { session: null, client: null, did: null }; + } + + try { + const oauth = createOAuthClient(env); + const session = await oauth.restore(did); + + return { + session, + client: new Client({ handler: session }), + did + }; + } catch (e) { + console.error('Failed to restore session:', e); + cookies.delete('did', { path: '/' }); + return { session: null, client: null, did: null }; + } +} +``` + +### `src/lib/atproto/server/profile.ts` + +```ts +import type { Did } from '@atcute/lexicons'; +import { getDetailedProfile, describeRepo } from '../methods'; + +const PROFILE_CACHE_TTL = 60 * 60; // 1 hour + +export async function loadProfile(did: Did, profileCache?: KVNamespace) { + if (profileCache) { + try { + const cached = await profileCache.get(did, 'json'); + if (cached) return cached as Record; + } catch { + // Cache read failed, continue to fresh fetch + } + } + + const profile = await fetchProfile(did); + + if (profileCache && profile) { + profileCache + .put(did, JSON.stringify(profile), { expirationTtl: PROFILE_CACHE_TTL }) + .catch(() => {}); + } + + return profile; +} + +async function fetchProfile(did: Did) { + try { + let profile = await getDetailedProfile({ did }); + + if (!profile || profile.handle === 'handle.invalid') { + const repo = await describeRepo({ did }); + profile = { + did, + handle: repo?.handle || 'handle.invalid' + } as typeof profile; + } + + return profile; + } catch (e) { + console.error('Failed to load profile:', e); + return undefined; + } +} +``` + +### `src/lib/atproto/scripts/generate-key.ts` + +```ts +import { generateClientAssertionKey } from '@atcute/oauth-node-client'; + +const key = await generateClientAssertionKey('main-key'); +console.log(JSON.stringify(key)); +``` + +### `src/lib/atproto/scripts/generate-secret.ts` + +```ts +import { randomBytes } from 'node:crypto'; + +console.log(randomBytes(32).toString('base64url')); +``` + +### `src/lib/atproto/scripts/setup-dev.ts` + +```ts +import { existsSync } from 'node:fs'; +import { copyFile, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { randomBytes } from 'node:crypto'; + +import { generateClientAssertionKey } from '@atcute/oauth-node-client'; + +const cwd = process.cwd(); +const examplePath = resolve(cwd, '.env.example'); +const envPath = resolve(cwd, '.env'); + +if (!existsSync(envPath)) { + if (!existsSync(examplePath)) { + throw new Error(`missing .env.example (expected at ${examplePath})`); + } + await copyFile(examplePath, envPath); + console.log(`created ${envPath}`); +} + +const upsertVar = (input: string, key: string, value: string): string => { + const line = `${key}=${value}`; + const re = new RegExp(`^${key}=.*$`, 'm'); + + if (re.test(input)) { + const match = input.match(re); + const current = match ? match[0].slice(key.length + 1).trim() : ''; + if (current === '' || current === "''" || current === '""' || current.includes('...')) { + return input.replace(re, line); + } + return input; + } + + const suffix = input.endsWith('\n') || input.length === 0 ? '' : '\n'; + return `${input}${suffix}${line}\n`; +}; + +let vars = await readFile(envPath, 'utf8'); + +const secret = randomBytes(32).toString('base64url'); +vars = upsertVar(vars, 'COOKIE_SECRET', secret); + +const jwk = await generateClientAssertionKey('main-key'); +vars = upsertVar(vars, 'CLIENT_ASSERTION_KEY', JSON.stringify(jwk)); + +await writeFile(envPath, vars); +console.log(`updated ${envPath}`); +``` + +### `src/routes/(oauth)/oauth/callback/+server.ts` + +```ts +import { redirect } from '@sveltejs/kit'; +import { createOAuthClient } from '$lib/atproto/server/oauth'; +import { setSignedCookie } from '$lib/atproto/server/signed-cookie'; +import { dev } from '$app/environment'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, platform, cookies }) => { + const oauth = createOAuthClient(platform?.env); + + try { + const { session } = await oauth.callback(url.searchParams); + + setSignedCookie(cookies, 'did', session.did, { + path: '/', + httpOnly: true, + secure: !dev, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 180 // 180 days + }); + } catch (e) { + console.error('OAuth callback failed:', e); + redirect(303, '/?error=auth_failed'); + } + + redirect(303, '/'); +}; +``` + +### `src/routes/(oauth)/oauth/jwks.json/+server.ts` + +```ts +import { json } from '@sveltejs/kit'; +import { createOAuthClient } from '$lib/atproto/server/oauth'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ platform }) => { + const oauth = createOAuthClient(platform?.env); + return json(oauth.jwks ?? { keys: [] }); +}; +``` + +### `src/routes/(oauth)/oauth-client-metadata.json/+server.ts` + +```ts +import { json } from '@sveltejs/kit'; +import { createOAuthClient } from '$lib/atproto/server/oauth'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ platform }) => { + const oauth = createOAuthClient(platform?.env); + return json(oauth.metadata); +}; +``` + +### `.env.example` + +``` +# Generate both with: pnpm env:setup-dev +CLIENT_ASSERTION_KEY= +COOKIE_SECRET= + +# Set to your tunnel URL to use a confidential client in dev +# OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com +``` + +## Step 3: Modify existing files + +### `src/app.d.ts` + +Add these to the existing `App` namespace. Merge with any existing `Locals` or `Platform` fields — do not remove existing fields. + +```ts +import type { OAuthSession } from '@atcute/oauth-node-client'; +import type { Client } from '@atcute/client'; +import type { Did } from '@atcute/lexicons'; +``` + +Add to `App.Locals`: + +```ts +session: OAuthSession | null; +client: Client | null; +did: Did | null; +``` + +Add to `App.Platform`: + +```ts +env: { + OAUTH_SESSIONS: KVNamespace; + OAUTH_STATES: KVNamespace; + CLIENT_ASSERTION_KEY: string; + COOKIE_SECRET: string; + OAUTH_PUBLIC_URL: string; + PROFILE_CACHE?: KVNamespace; +}; +``` + +Add at the bottom of the file (for lexicon type augmentation): + +```ts +import type {} from '@atcute/atproto'; +import type {} from '@atcute/bluesky'; +``` + +### `src/hooks.server.ts` + +Add session restoration. If the file already has a `handle` export, wrap both in `sequence()` from `@sveltejs/kit`. + +```ts +import type { Handle } from '@sveltejs/kit'; +import { restoreSession } from '$lib/atproto/server/session'; + +const atprotoHandle: Handle = async ({ event, resolve }) => { + const { session, client, did } = await restoreSession( + event.cookies, event.platform?.env + ); + event.locals.session = session; + event.locals.client = client; + event.locals.did = did; + return resolve(event); +}; +``` + +If no existing hooks: `export const handle = atprotoHandle;` + +If existing hooks: `export const handle = sequence(existingHandle, atprotoHandle);` (import `sequence` from `@sveltejs/kit`) + +### `src/routes/+layout.server.ts` + +Add profile loading. Merge with any existing load function. + +```ts +import type { LayoutServerLoad } from './$types'; +import { loadProfile } from '$lib/atproto/server/profile'; + +export const load: LayoutServerLoad = async ({ locals, platform }) => { + if (!locals.did) return { did: null, profile: null }; + const profile = await loadProfile(locals.did, platform?.env?.PROFILE_CACHE); + return { did: locals.did, profile }; +}; +``` + +If a load function already exists, merge the profile data into its return value. + +### `src/routes/+layout.svelte` (foxui only) + +Only if the user chose `foxui`. Add the login modal to the existing layout: + +```svelte + + + + { + await login(handle); + return true; + }} + signup={async () => { + signup(); + return true; + }} +/> +``` + +To show the modal from anywhere, use `@foxui/social` state and `@foxui/core` components: + +```svelte + + +{#if user.isLoggedIn} +

Signed in as {user.profile?.handle ?? user.did}

+ +{:else} + +{/if} +``` + +`@foxui/core` also exports `Avatar`, `Input`, and other UI primitives you can use. + +### `src/routes/user/+page.svelte` (basic only) + +Only if the user chose `basic`. Create this file: + +```svelte + + +
+ {#if user.isLoggedIn} +

Signed in as {user.profile?.handle ?? user.did}

+ + {:else} +

Sign in

+
+ + {#if error} +

{error}

+ {/if} + +
+ {/if} +
+``` + +If the project does not use Tailwind, replace the Tailwind classes with plain inline styles. + +### `svelte.config.js` + +Add `remoteFunctions: true` inside `kit.experimental`: + +```js +kit: { + adapter: adapter(), + experimental: { + remoteFunctions: true + } +} +``` + +If `experimental` already exists, merge into it. Do not remove other experimental flags. + +### `vite.config.ts` + +Add dev server config for loopback OAuth: + +```ts +server: { + host: '127.0.0.1', + port: 5183 +} +``` + +Add this inside `defineConfig()`. Do not remove existing plugins or config. + +### `wrangler.jsonc` + +Add or merge these fields: + +- Add `"nodejs_compat_v2"` to `compatibility_flags` (create the array if it doesn't exist) +- Add `"OAUTH_PUBLIC_URL": "https://your-domain.com"` to `vars` (create `vars` if needed) +- Add KV namespace placeholders to `kv_namespaces`: + +```jsonc +{ "binding": "OAUTH_SESSIONS", "id": "TODO" }, +{ "binding": "OAUTH_STATES", "id": "TODO" } +``` + +Do not remove existing bindings or vars. + +### `tsconfig.json` + +Add `"@cloudflare/workers-types"` to `compilerOptions.types`. Create the `types` array if it doesn't exist. + +### `package.json` + +Add these to the `scripts` section: + +```json +"env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts", +"env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts", +"env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts" +``` + +### `.gitignore` + +Ensure these lines are present: + +``` +.env +.env.* +!.env.example +``` + +## Step 4: Run setup and verify + +1. Run `pnpm env:setup-dev` to generate secrets in `.env` +2. Run `pnpm dev` to start the dev server +3. Verify it starts on `http://127.0.0.1:5183` +4. Tell the user: + - Dev mode uses a loopback client (no keys needed) + - 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` + +## Usage examples + +### Login / Logout + +```svelte + + +{#if user.isLoggedIn} +

Signed in as {user.did}

+ +{:else} + +{/if} +``` + +### Write operations + +```ts +import { putRecord, deleteRecord, uploadBlob, createTID } from '$lib/atproto'; + +await putRecord({ + collection: 'your.collection.name', + rkey: createTID(), + record: { text: 'hello', createdAt: new Date().toISOString() } +}); + +await deleteRecord({ collection: 'your.collection.name', rkey: 'some-key' }); + +const blob = await uploadBlob({ blob: file }); +``` + +### Read operations (no auth needed) + +```ts +import { listRecords, getRecord, getDetailedProfile } from '$lib/atproto'; + +const records = await listRecords({ did: 'did:plc:...', collection: 'your.collection.name' }); +const profile = await getDetailedProfile({ did: 'did:plc:...' }); +``` diff --git a/README.md b/README.md index 55472ab..0dfdd86 100644 --- a/README.md +++ b/README.md @@ -1,163 +1,144 @@ -# svelte atproto cloudflare workers oauth demo +# svelte atproto cloudflare workers oauth -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. +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. -## Prerequisites - -- [Node.js](https://nodejs.org/) (v18+) -- [pnpm](https://pnpm.io/) -- A [Cloudflare account](https://dash.cloudflare.com/sign-up) -- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) (`pnpm add -g wrangler`) -- A domain pointed at Cloudflare (for production) - -## Local Development - -### 1. Install dependencies +## Quick Start ```sh pnpm install +pnpm dev ``` -### 2. Run the dev server +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. -```sh -pnpm dev -``` +### Dev with tunnel (confidential client) -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. +To test the full production flow locally with a tunnel like [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/): -No Cloudflare setup is needed for local development. +```sh +pnpm env:setup-dev # generates secrets in .env +# add OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com to .env +cloudflared tunnel --url http://localhost:5183 # start tunnel +pnpm dev # start dev server +``` ## Production Deployment ### 1. Create KV namespaces -Create two KV namespaces for storing OAuth sessions and states: - ```sh npx wrangler kv namespace create OAUTH_SESSIONS npx wrangler kv namespace create OAUTH_STATES ``` -Each command outputs an ID. Update `wrangler.toml` with the actual IDs: - -```toml -[[kv_namespaces]] -binding = "OAUTH_SESSIONS" -id = "" +Add the IDs to `wrangler.jsonc`: -[[kv_namespaces]] -binding = "OAUTH_STATES" -id = "" +```jsonc +"kv_namespaces": [ + { "binding": "OAUTH_SESSIONS", "id": "" }, + { "binding": "OAUTH_STATES", "id": "" } +] ``` -### 2. Generate a client assertion key +### 2. Set your public URL -AT Protocol OAuth requires a confidential client with a private key for production. Generate one: +In `wrangler.jsonc`: -```sh -pnpm generate-key +```jsonc +"vars": { + "OAUTH_PUBLIC_URL": "https://your-domain.com" +} ``` -This outputs a JSON key. Add it as a Cloudflare Workers secret: +### 3. Generate and set secrets ```sh -npx wrangler secret put CLIENT_ASSERTION_KEY -``` +pnpm env:generate-key +npx wrangler secret put CLIENT_ASSERTION_KEY # paste generated key -When prompted, paste the JSON key value. +pnpm env:generate-secret +npx wrangler secret put COOKIE_SECRET # paste generated secret +``` -### 3. Configure your domain +### 4. Configure permissions -Edit `src/lib/atproto/settings.ts` and set `SITE` to your production domain: +Edit `src/lib/atproto/settings.ts`: ```ts -export const SITE = dev ? 'http://localhost:5183' : 'https://your-domain.com'; +export const permissions = { + collections: ['xyz.statusphere.status'], // collections your app can read/write + rpc: {}, // authenticated RPC requests + blobs: [] // blob types your app can upload +} as const; ``` -### 4. Serve the OAuth client metadata +The OAuth scope is auto-generated from this config. -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`. +### 5. Deploy -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: - -```json -{ - "client_id": "https://your-domain.com/oauth-client-metadata.json", - "redirect_uris": ["https://your-domain.com/oauth/callback"], - "scope": "atproto repo:xyz.statusphere.status", - "jwks_uri": "https://your-domain.com/oauth/jwks.json" -} +```sh +npx wrangler deploy ``` -The scope is auto-generated from the `permissions` config in `src/lib/atproto/settings.ts`. +Set up a custom domain in the Cloudflare dashboard (Worker > Settings > Domains & Routes) so the OAuth client metadata URL matches your `client_id`. -### 5. Configure permissions - -Edit the `permissions` object in `src/lib/atproto/settings.ts` to control what your app can do: - -```ts -export const permissions = { - // collections your app can create/delete/update records in - collections: ['xyz.statusphere.status'], +## Scripts - // authenticated proxied RPC requests (e.g. to appview services) - rpc: {}, +| Script | Description | +|---|---| +| `pnpm dev` | Start dev server | +| `pnpm build` | Build for production | +| `pnpm check` | Run svelte-check | +| `pnpm env:generate-key` | Generate client assertion key | +| `pnpm env:generate-secret` | Generate cookie signing secret | +| `pnpm env:setup-dev` | Generate both and write to `.env` | - // blob types your app can upload - blobs: [] -} as const; -``` +## Adding to an existing project -### 6. Deploy +**With an AI agent** — paste this into Claude Code (or similar) in your existing repo: -```sh -pnpm build -npx wrangler deploy ``` - -Or deploy directly: - -```sh -npx wrangler deploy +add atproto oauth to this project https://raw.githubusercontent.com/flo-bit/svelte-atproto-oauth-cloudflare-workers/main/AGENT_SETUP.md ``` -### 7. Set up a custom domain (recommended) +The [agent prompt](AGENT_SETUP.md) will ask you a few questions and set everything up. -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`. +**Manually** — see [SETUP.md](SETUP.md) for a step-by-step guide. ## Project Structure ``` -src/ -├── lib/ -│ ├── atproto/ -│ │ ├── auth.svelte.ts # Client-side auth state (derived from server data) -│ │ ├── metadata.ts # OAuth client metadata construction -│ │ ├── methods.ts # AT Protocol helper methods -│ │ ├── oauth.remote.ts # Remote functions: login, logout -│ │ ├── repo.remote.ts # Remote functions: putRecord, deleteRecord, uploadBlob -│ │ └── settings.ts # Site URL, permissions, constants -│ └── server/ -│ ├── oauth.ts # OAuthClient factory (dev vs prod) -│ └── kv-store.ts # Cloudflare KV-backed Store implementation -├── routes/ -│ ├── oauth/ -│ │ ├── callback/ # OAuth callback handler (GET redirect) -│ │ └── jwks.json/ # Public JWKS endpoint -│ ├── +layout.server.ts # Loads user profile from session -│ ├── +layout.svelte -│ ├── +page.server.ts # Loads page-specific data -│ └── +page.svelte -└── hooks.server.ts # Session restoration from cookie +src/lib/atproto/ +├── auth.svelte.ts # Client-side auth state & login/logout/signup +├── index.ts # Public exports +├── metadata.ts # OAuth scope from permissions +├── methods.ts # AT Protocol helpers (read/write/resolve) +├── settings.ts # Permissions config, constants +├── server/ +│ ├── oauth.ts # OAuthClient factory (loopback vs confidential) +│ ├── oauth.remote.ts # Remote functions: login, logout +│ ├── repo.remote.ts # Remote functions: putRecord, deleteRecord, uploadBlob +│ ├── session.ts # Session restoration from signed cookie +│ ├── profile.ts # Profile loading with optional KV cache +│ ├── kv-store.ts # Cloudflare KV-backed Store +│ └── signed-cookie.ts # HMAC-signed cookie helpers +└── scripts/ + ├── generate-key.ts + ├── generate-secret.ts + └── setup-dev.ts + +src/routes/(oauth)/ +├── oauth/callback/+server.ts +├── oauth/jwks.json/+server.ts +└── oauth-client-metadata.json/+server.ts ``` ## How It Works -- **Authentication**: Server-side OAuth flow via `@atcute/oauth-node-client`. Sessions are stored in Cloudflare KV and identified by a `did` cookie. -- **Remote functions**: Write operations (putRecord, deleteRecord, uploadBlob) and auth actions (login, logout) use SvelteKit remote functions — type-safe server calls without manual API routes. -- **Dev mode**: Uses AT Protocol's loopback client (no keys, in-memory storage). -- **Prod mode**: Uses a confidential client with `private_key_jwt` assertion and KV-backed stores. +- **Auth**: Server-side OAuth via `@atcute/oauth-node-client`. Sessions stored in KV, identified by HMAC-signed `did` cookie. +- **Remote functions**: Write operations and auth actions use SvelteKit remote functions — type-safe server calls without manual API routes. +- **Dev mode**: Loopback client by default. Set `OAUTH_PUBLIC_URL` in `.env` for confidential client via tunnel. +- **Prod mode**: Confidential client with `private_key_jwt`, KV stores, `OAUTH_PUBLIC_URL` from `wrangler.jsonc`. ## License diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..caf1f53 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,276 @@ +# Adding AT Protocol OAuth to your SvelteKit + Cloudflare Workers project + +## 1. Install dependencies + +```sh +pnpm add valibot +pnpm add -D @atcute/oauth-node-client @atcute/identity-resolver @atcute/lexicons @atcute/client @atcute/tid @cloudflare/workers-types tsx +``` + +Add any lexicon types you need (e.g. `@atcute/atproto`, `@atcute/bluesky`). + +## 2. Copy files + +Copy these into your project: + +- `src/lib/atproto/` — auth state, methods, server logic, scripts +- `src/routes/(oauth)/` — OAuth callback, JWKS, and client metadata endpoints + +## 3. Configure + +**`src/lib/atproto/settings.ts`** — set your app's permissions: + +```ts +export const permissions = { + collections: ['your.collection.name'], + rpc: {}, + blobs: [] +} as const; +``` + +The OAuth scope is auto-generated from this config. + +**`src/app.d.ts`** — add session types: + +```ts +import type { OAuthSession } from '@atcute/oauth-node-client'; +import type { Client } from '@atcute/client'; +import type { Did } from '@atcute/lexicons'; + +declare global { + namespace App { + interface Locals { + session: OAuthSession | null; + client: Client | null; + did: Did | null; + } + interface Platform { + env: { + OAUTH_SESSIONS: KVNamespace; + OAUTH_STATES: KVNamespace; + CLIENT_ASSERTION_KEY: string; + COOKIE_SECRET: string; + OAUTH_PUBLIC_URL: string; + PROFILE_CACHE?: KVNamespace; // optional + }; + } + } +} + +import type {} from '@atcute/atproto'; +import type {} from '@atcute/bluesky'; +export {}; +``` + +**`src/hooks.server.ts`** — restore session on every request: + +```ts +import type { Handle } from '@sveltejs/kit'; +import { restoreSession } from '$lib/atproto/server/session'; + +export const handle: Handle = async ({ event, resolve }) => { + const { session, client, did } = await restoreSession( + event.cookies, event.platform?.env + ); + event.locals.session = session; + event.locals.client = client; + event.locals.did = did; + return resolve(event); +}; +``` + +If you already have hooks, use SvelteKit's `sequence` helper to combine them. + +**`wrangler.jsonc`** — add KV namespaces and public URL: + +```sh +npx wrangler kv namespace create OAUTH_SESSIONS +npx wrangler kv namespace create OAUTH_STATES +``` + +```jsonc +{ + "compatibility_flags": ["nodejs_compat_v2"], + "vars": { + "OAUTH_PUBLIC_URL": "https://your-domain.com" + }, + "kv_namespaces": [ + { "binding": "OAUTH_SESSIONS", "id": "" }, + { "binding": "OAUTH_STATES", "id": "" } + ] +} +``` + +**`package.json`** — add helper scripts: + +```json +{ + "env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts", + "env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts", + "env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts" +} +``` + +**`.env.example`**: + +``` +CLIENT_ASSERTION_KEY= +COOKIE_SECRET= +# Set to your tunnel URL to use a confidential client in dev +OAUTH_PUBLIC_URL= +``` + +## 4. Load profile (optional) + +Add a `src/routes/+layout.server.ts` to load the user's Bluesky profile on every page: + +```ts +import type { LayoutServerLoad } from './$types'; +import { loadProfile } from '$lib/atproto/server/profile'; + +export const load: LayoutServerLoad = async ({ locals, platform }) => { + if (!locals.did) return { did: null, profile: null }; + const profile = await loadProfile(locals.did, platform?.env?.PROFILE_CACHE); + return { did: locals.did, profile }; +}; +``` + +For optional profile caching, create a KV namespace and add it to `wrangler.jsonc`: + +```sh +npx wrangler kv namespace create PROFILE_CACHE +``` + +## 5. Generate secrets + +For local dev: + +```sh +pnpm env:setup-dev +``` + +For production: + +```sh +pnpm env:generate-key +npx wrangler secret put CLIENT_ASSERTION_KEY # paste the generated key + +pnpm env:generate-secret +npx wrangler secret put COOKIE_SECRET # paste the generated secret +``` + +## 6. Add login UI + +### Option A: `@foxui/social` login modal (recommended) + +Install the UI packages: + +```sh +pnpm add @foxui/social @foxui/core +``` + +Add the login modal to your root layout (`src/routes/+layout.svelte`): + +```svelte + + +{@render children()} + + { + await login(handle); + return true; + }} + signup={async () => { + signup(); + return true; + }} +/> +``` + +Then open the modal from anywhere: + +```svelte + + +{#if user.isLoggedIn} +

Signed in as {user.profile?.handle ?? user.did}

+ +{:else} + +{/if} +``` + +### Option B: Simple inline login + +```svelte + + +{#if user.isLoggedIn} +

Signed in as {user.did}

+ +{:else} + +{/if} +``` + +### Write operations + +```ts +import { putRecord, deleteRecord, uploadBlob } from '$lib/atproto'; + +await putRecord({ + collection: 'your.collection.name', + rkey: 'some-key', + record: { text: 'hello', createdAt: new Date().toISOString() } +}); + +await deleteRecord({ collection: 'your.collection.name', rkey: 'some-key' }); + +const blob = await uploadBlob({ blob: file }); +``` + +### Read operations (no auth needed) + +```ts +import { listRecords, getRecord, getDetailedProfile } from '$lib/atproto'; + +const records = await listRecords({ did: 'did:plc:...', collection: 'your.collection.name' }); +const profile = await getDetailedProfile({ did: 'did:plc:...' }); +``` + +### Server load functions + +```ts +export const load = async ({ locals }) => { + if (!locals.client || !locals.did) return { data: null }; + + const response = await locals.client.get('com.atproto.repo.listRecords', { + params: { repo: locals.did, collection: 'your.collection.name' } + }); + + return { data: response.data }; +}; +``` + +## Dev with tunnel (optional) + +To test the confidential client flow locally: + +1. `pnpm env:setup-dev` +2. Add tunnel URL to `.env`: `OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com` +3. `cloudflared tunnel --url http://localhost:5183` +4. `pnpm dev` + +Without `OAUTH_PUBLIC_URL`, dev mode uses a loopback public client (no keys needed). diff --git a/package.json b/package.json index 1c45ef2..3dd7008 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", "lint": "prettier --check . && eslint .", - "generate-key": "npx tsx scripts/generate-key.ts" + "env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts", + "env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts", + "env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts" }, "devDependencies": { "@atcute/atproto": "^3.1.10", @@ -49,6 +51,9 @@ }, "license": "MIT", "dependencies": { + "@foxui/core": "^0.5.1", + "@foxui/social": "^0.5.1", + "@foxui/time": "^0.5.1", "valibot": "^1.2.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28be468..6733a73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@foxui/core': + specifier: ^0.5.1 + 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) + '@foxui/social': + specifier: ^0.5.1 + 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) + '@foxui/time': + specifier: ^0.5.1 + 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) valibot: specifier: ^1.2.0 version: 1.2.0(typescript@5.9.3) @@ -167,6 +176,27 @@ packages: '@atcute/util-text@1.1.1': resolution: {integrity: sha512-JH0SxzUQJAmbOBTYyhxQbkkI6M33YpjlVLEcbP5GYt43xgFArzV0FJVmEpvIj0kjsmphHB45b6IitdvxPdec9w==, tarball: https://registry.npmjs.org/@atcute/util-text/-/util-text-1.1.1.tgz} + '@atproto/api@0.18.21': + resolution: {integrity: sha512-s35MIJerGT/pKe2xJtKKswqlIr/ola2r2iURBKBL0Mk1OKe6jP4YvTMh1N2d2PEANFzNNTbKoDaLfJPo2Uvc/w==, tarball: https://registry.npmjs.org/@atproto/api/-/api-0.18.21.tgz} + + '@atproto/common-web@0.4.17': + 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} + + '@atproto/lex-data@0.0.12': + resolution: {integrity: sha512-aekJudcK1p6sbTqUv2bJMJBAGZaOJS0mgDclpK3U6VuBREK/au4B6ffunBFWgrDfg0Vwj2JGyEA7E51WZkJcRw==, tarball: https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.12.tgz} + + '@atproto/lex-json@0.0.12': + resolution: {integrity: sha512-XlEpnWWZdDJ5BIgG25GyH+6iBfyrFL18BI5JSE6rUfMObbFMrQRaCuRLQfryRXNysVz3L3U+Qb9y8KcXbE8AcA==, tarball: https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.12.tgz} + + '@atproto/lexicon@0.6.1': + resolution: {integrity: sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==, tarball: https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.1.tgz} + + '@atproto/syntax@0.4.3': + resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==, tarball: https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz} + + '@atproto/xrpc@0.7.7': + resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==, tarball: https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz} + '@badrap/valita@0.4.6': resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==, tarball: https://registry.npmjs.org/@badrap/valita/-/valita-0.4.6.tgz} engines: {node: '>= 18'} @@ -596,6 +626,24 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==, tarball: https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz} + '@foxui/core@0.5.1': + resolution: {integrity: sha512-WhcwTYy2bipsfrBmNjHF88gfmigr10KeWqNBVrwWmkFjAeXy8J9rj9CSLpeRQCHMmrNDqjVq7VahxWRuz0NO9g==, tarball: https://registry.npmjs.org/@foxui/core/-/core-0.5.1.tgz} + peerDependencies: + svelte: '>=5' + tailwindcss: '>=3' + + '@foxui/social@0.5.1': + resolution: {integrity: sha512-PupbByfLdykQqzxjTZkMpy43LHaLrivI0DEb9Psew0+1PCMDqFSoDk00BVl5vtaPOlBfOwdQsDICa4kR0LApHg==, tarball: https://registry.npmjs.org/@foxui/social/-/social-0.5.1.tgz} + peerDependencies: + svelte: '>=5' + tailwindcss: '>=3' + + '@foxui/time@0.5.1': + resolution: {integrity: sha512-P7mLM0UVarRdAI0owUUVzsa+VTp+ZQWENNM9BD0H9VESG49dIwP6sbfCRFFecCLijUHP+aIcnO3bnuK9BDx3rQ==, tarball: https://registry.npmjs.org/@foxui/time/-/time-0.5.1.tgz} + peerDependencies: + svelte: '>=5' + tailwindcss: '>=3' + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==, tarball: https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz} engines: {node: '>=18.18.0'} @@ -771,6 +819,11 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz} + '@number-flow/svelte@0.3.13': + resolution: {integrity: sha512-mvbxDeSFa1o/E4vGhrWuawAFCgcn5qTQ/s++FIoD88es5+JQa/aMQUypTy7qXIreTtTvncpIbkKdw9DMnweaSw==, tarball: https://registry.npmjs.org/@number-flow/svelte/-/svelte-0.3.13.tgz} + peerDependencies: + svelte: ^4 || ^5 + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==, tarball: https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz} @@ -1132,6 +1185,12 @@ packages: resolution: {integrity: sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==, tarball: https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.1.tgz} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@use-gesture/core@10.3.1': + resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==, tarball: https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz} + + '@use-gesture/vanilla@10.3.1': + resolution: {integrity: sha512-lT4scGLu59ovA3zmtUonukAGcA0AdOOh+iwNDS05Bsu7Lq9aZToDHhI6D8Q2qvsVraovtsLLYwPrWdG/noMAKw==, tarball: https://registry.npmjs.org/@use-gesture/vanilla/-/vanilla-10.3.1.tgz} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==, tarball: https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz} peerDependencies: @@ -1156,6 +1215,9 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==, tarball: https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz} engines: {node: '>= 0.4'} + await-lock@2.2.2: + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==, tarball: https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==, tarball: https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz} engines: {node: '>= 0.4'} @@ -1170,6 +1232,13 @@ packages: '@internationalized/date': ^3.8.1 svelte: ^5.33.0 + bits-ui@2.16.2: + resolution: {integrity: sha512-bgEpRRF7Ck9nRP1pbuKVxpaSMrz+8Pm0y+dmuvlkrSe+uUwIQECef29y6eslFHM6pCAubUh7STrsTLUUp8fzFQ==, tarball: https://registry.npmjs.org/bits-ui/-/bits-ui-2.16.2.tgz} + engines: {node: '>=20'} + peerDependencies: + '@internationalized/date': ^3.8.1 + svelte: ^5.33.0 + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==, tarball: https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz} @@ -1216,6 +1285,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==, tarball: https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz} engines: {node: '>=18'} + core-js@3.48.0: + resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==, tarball: https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, tarball: https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz} engines: {node: '>= 8'} @@ -1225,6 +1297,9 @@ packages: engines: {node: '>=4'} hasBin: true + custom-event-polyfill@1.0.7: + resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==, tarball: https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==, tarball: https://registry.npmjs.org/debug/-/debug-4.4.3.tgz} engines: {node: '>=6.0'} @@ -1252,6 +1327,9 @@ packages: devalue@5.6.2: resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==, tarball: https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz} + emoji-picker-element@1.29.0: + resolution: {integrity: sha512-lQm8YayfwIP5j+Xe1O2Fjul7hv2b4spPS16X99O4qKQdzDQaEeiAqqYaRONHncewcpisUf6qGFJkhM2G3riEdA==, tarball: https://registry.npmjs.org/emoji-picker-element/-/emoji-picker-element-1.29.0.tgz} + enhanced-resolve@5.18.4: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==, tarball: https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz} engines: {node: '>=10.13.0'} @@ -1401,6 +1479,9 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, tarball: https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz} engines: {node: '>=8'} + hls.js@1.6.15: + resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==, tarball: https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, tarball: https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz} engines: {node: '>= 4'} @@ -1420,6 +1501,9 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==, tarball: https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz} + is-emoji-supported@0.0.5: + resolution: {integrity: sha512-WOlXUhDDHxYqcSmFZis+xWhhqXiK2SU0iYiqmth5Ip0FHLZQAt9rKL5ahnilE8/86WH8tZ3bmNNNC+bTzamqlw==, tarball: https://registry.npmjs.org/is-emoji-supported/-/is-emoji-supported-0.0.5.tgz} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, tarball: https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz} engines: {node: '>=0.10.0'} @@ -1434,6 +1518,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, tarball: https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz} + iso-datestring-validator@2.2.2: + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==, tarball: https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==, tarball: https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz} hasBin: true @@ -1539,6 +1626,9 @@ packages: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==, tarball: https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz} engines: {node: '>=10'} + loadjs@4.3.0: + resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==, tarball: https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz} + locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==, tarball: https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz} @@ -1572,6 +1662,11 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==, tarball: https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz} engines: {node: '>=16 || 14 >=14.17'} + mode-watcher@1.1.0: + resolution: {integrity: sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==, tarball: https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz} + peerDependencies: + svelte: ^5.27.0 + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==, tarball: https://registry.npmjs.org/mri/-/mri-1.2.0.tgz} engines: {node: '>=4'} @@ -1583,6 +1678,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, tarball: https://registry.npmjs.org/ms/-/ms-2.1.3.tgz} + multiformats@9.9.0: + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==, tarball: https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1600,6 +1698,9 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==, tarball: https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz} hasBin: true + number-flow@0.5.12: + resolution: {integrity: sha512-CIs21h2JkfYG4rfgERaUNAk0Cz+Ef14fNJfSCbGGhgRgconQc9b7rcCQfi9SZ36kNjVXmsl2BrzDbjGtEgumAA==, tarball: https://registry.npmjs.org/number-flow/-/number-flow-0.5.12.tgz} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==, tarball: https://registry.npmjs.org/obug/-/obug-2.1.1.tgz} @@ -1640,6 +1741,9 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz} engines: {node: '>=12'} + plyr@3.8.4: + resolution: {integrity: sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==, tarball: https://registry.npmjs.org/plyr/-/plyr-3.8.4.tgz} + postcss-load-config@3.1.4: resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==, tarball: https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz} engines: {node: '>= 10'} @@ -1746,6 +1850,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, tarball: https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz} engines: {node: '>=6'} + rangetouch@2.0.1: + resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==, tarball: https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz} engines: {node: '>= 14.18.0'} @@ -1766,6 +1873,21 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + runed@0.23.4: + resolution: {integrity: sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==, tarball: https://registry.npmjs.org/runed/-/runed-0.23.4.tgz} + peerDependencies: + svelte: ^5.7.0 + + runed@0.25.0: + resolution: {integrity: sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==, tarball: https://registry.npmjs.org/runed/-/runed-0.25.0.tgz} + peerDependencies: + svelte: ^5.7.0 + + runed@0.28.0: + resolution: {integrity: sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==, tarball: https://registry.npmjs.org/runed/-/runed-0.28.0.tgz} + peerDependencies: + svelte: ^5.7.0 + runed@0.35.1: resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==, tarball: https://registry.npmjs.org/runed/-/runed-0.35.1.tgz} peerDependencies: @@ -1839,12 +1961,23 @@ packages: svelte: optional: true + svelte-sonner@1.0.7: + resolution: {integrity: sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A==, tarball: https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.0.7.tgz} + peerDependencies: + svelte: ^5.0.0 + svelte-toolbelt@0.10.6: resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==, tarball: https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz} engines: {node: '>=18', pnpm: '>=8.7.0'} peerDependencies: svelte: ^5.30.2 + svelte-toolbelt@0.7.1: + resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==, tarball: https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.0.0 + svelte@5.48.0: resolution: {integrity: sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==, tarball: https://registry.npmjs.org/svelte/-/svelte-5.48.0.tgz} engines: {node: '>=18'} @@ -1852,6 +1985,19 @@ packages: tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==, tarball: https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==, tarball: https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz} + + tailwind-variants@3.2.2: + resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==, tarball: https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz} + engines: {node: '>=16.x', pnpm: '>=7.x'} + peerDependencies: + tailwind-merge: '>=3.0.0' + tailwindcss: '*' + peerDependenciesMeta: + tailwind-merge: + optional: true + tailwindcss@4.1.18: resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==, tarball: https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz} @@ -1863,6 +2009,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz} engines: {node: '>=12.0.0'} + tlds@1.261.0: + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==, tarball: https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz} + hasBin: true + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==, tarball: https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz} engines: {node: '>=6'} @@ -1897,6 +2047,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uint8arrays@3.0.0: + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==, tarball: https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz} @@ -1913,6 +2066,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, tarball: https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz} + url-polyfill@1.1.14: + resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==, tarball: https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, tarball: https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz} @@ -2029,6 +2185,9 @@ packages: zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==, tarball: https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz} + snapshots: '@atcute/atproto@3.1.10': @@ -2131,6 +2290,53 @@ snapshots: dependencies: unicode-segmenter: 0.14.5 + '@atproto/api@0.18.21': + dependencies: + '@atproto/common-web': 0.4.17 + '@atproto/lexicon': 0.6.1 + '@atproto/syntax': 0.4.3 + '@atproto/xrpc': 0.7.7 + await-lock: 2.2.2 + multiformats: 9.9.0 + tlds: 1.261.0 + zod: 3.25.76 + + '@atproto/common-web@0.4.17': + dependencies: + '@atproto/lex-data': 0.0.12 + '@atproto/lex-json': 0.0.12 + '@atproto/syntax': 0.4.3 + zod: 3.25.76 + + '@atproto/lex-data@0.0.12': + dependencies: + multiformats: 9.9.0 + tslib: 2.8.1 + uint8arrays: 3.0.0 + unicode-segmenter: 0.14.5 + + '@atproto/lex-json@0.0.12': + dependencies: + '@atproto/lex-data': 0.0.12 + tslib: 2.8.1 + + '@atproto/lexicon@0.6.1': + dependencies: + '@atproto/common-web': 0.4.17 + '@atproto/syntax': 0.4.3 + iso-datestring-validator: 2.2.2 + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/syntax@0.4.3': + dependencies: + tslib: 2.8.1 + + '@atproto/xrpc@0.7.7': + dependencies: + '@atproto/lexicon': 0.6.1 + zod: 3.25.76 + '@badrap/valita@0.4.6': {} '@cloudflare/kv-asset-handler@0.4.2': {} @@ -2390,6 +2596,49 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@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)': + dependencies: + '@number-flow/svelte': 0.3.13(svelte@5.48.0) + 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) + clsx: 2.1.1 + mode-watcher: 1.1.0(svelte@5.48.0) + svelte: 5.48.0 + svelte-sonner: 1.0.7(svelte@5.48.0) + tailwind-merge: 3.5.0 + tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.1.18) + tailwindcss: 4.1.18 + transitivePeerDependencies: + - '@internationalized/date' + - '@sveltejs/kit' + + '@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)': + dependencies: + '@atproto/api': 0.18.21 + '@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) + '@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) + '@use-gesture/vanilla': 10.3.1 + 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) + emoji-picker-element: 1.29.0 + hls.js: 1.6.15 + is-emoji-supported: 0.0.5 + plyr: 3.8.4 + svelte: 5.48.0 + tailwindcss: 4.1.18 + transitivePeerDependencies: + - '@internationalized/date' + - '@sveltejs/kit' + + '@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)': + dependencies: + '@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) + '@number-flow/svelte': 0.3.13(svelte@5.48.0) + 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) + svelte: 5.48.0 + tailwindcss: 4.1.18 + transitivePeerDependencies: + - '@internationalized/date' + - '@sveltejs/kit' + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -2525,6 +2774,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@number-flow/svelte@0.3.13(svelte@5.48.0)': + dependencies: + esm-env: 1.2.2 + number-flow: 0.5.12 + svelte: 5.48.0 + '@polka/url@1.0.0-next.29': {} '@poppinss/colors@4.1.6': @@ -2851,6 +3106,12 @@ snapshots: '@typescript-eslint/types': 8.53.1 eslint-visitor-keys: 4.2.1 + '@use-gesture/core@10.3.1': {} + + '@use-gesture/vanilla@10.3.1': + dependencies: + '@use-gesture/core': 10.3.1 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -2872,6 +3133,8 @@ snapshots: aria-query@5.3.2: {} + await-lock@2.2.2: {} + axobject-query@4.1.0: {} balanced-match@1.0.2: {} @@ -2889,6 +3152,19 @@ snapshots: transitivePeerDependencies: - '@sveltejs/kit' + 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): + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/dom': 1.7.4 + '@internationalized/date': 3.10.1 + esm-env: 1.2.2 + 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) + svelte: 5.48.0 + 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) + tabbable: 6.4.0 + transitivePeerDependencies: + - '@sveltejs/kit' + blake3-wasm@2.1.5: {} brace-expansion@1.1.12: @@ -2929,6 +3205,8 @@ snapshots: cookie@1.1.1: {} + core-js@3.48.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2937,6 +3215,8 @@ snapshots: cssesc@3.0.0: {} + custom-event-polyfill@1.0.7: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2951,6 +3231,8 @@ snapshots: devalue@5.6.2: {} + emoji-picker-element@1.29.0: {} + enhanced-resolve@5.18.4: dependencies: graceful-fs: 4.2.11 @@ -3161,6 +3443,8 @@ snapshots: has-flag@4.0.0: {} + hls.js@1.6.15: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3174,6 +3458,8 @@ snapshots: inline-style-parser@0.2.7: {} + is-emoji-supported@0.0.5: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -3186,6 +3472,8 @@ snapshots: isexe@2.0.0: {} + iso-datestring-validator@2.2.2: {} + jiti@2.6.1: {} js-yaml@4.1.1: @@ -3262,6 +3550,8 @@ snapshots: lilconfig@2.1.0: {} + loadjs@4.3.0: {} + locate-character@3.0.0: {} locate-path@6.0.0: @@ -3298,12 +3588,20 @@ snapshots: dependencies: brace-expansion: 2.0.2 + mode-watcher@1.1.0(svelte@5.48.0): + dependencies: + runed: 0.25.0(svelte@5.48.0) + svelte: 5.48.0 + svelte-toolbelt: 0.7.1(svelte@5.48.0) + mri@1.2.0: {} mrmime@2.0.1: {} ms@2.1.3: {} + multiformats@9.9.0: {} + nanoid@3.3.11: {} nanoid@5.1.6: {} @@ -3312,6 +3610,10 @@ snapshots: node-gyp-build@4.8.4: {} + number-flow@0.5.12: + dependencies: + esm-env: 1.2.2 + obug@2.1.1: {} optionator@0.9.4: @@ -3347,6 +3649,14 @@ snapshots: picomatch@4.0.3: {} + plyr@3.8.4: + dependencies: + core-js: 3.48.0 + custom-event-polyfill: 1.0.7 + loadjs: 4.3.0 + rangetouch: 2.0.1 + url-polyfill: 1.1.14 + postcss-load-config@3.1.4(postcss@8.5.6): dependencies: lilconfig: 2.1.0 @@ -3390,6 +3700,8 @@ snapshots: punycode@2.3.1: {} + rangetouch@2.0.1: {} + readdirp@4.1.2: {} regexparam@3.0.0: {} @@ -3429,6 +3741,21 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.56.0 fsevents: 2.3.3 + runed@0.23.4(svelte@5.48.0): + dependencies: + esm-env: 1.2.2 + svelte: 5.48.0 + + runed@0.25.0(svelte@5.48.0): + dependencies: + esm-env: 1.2.2 + svelte: 5.48.0 + + runed@0.28.0(svelte@5.48.0): + dependencies: + esm-env: 1.2.2 + svelte: 5.48.0 + 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): dependencies: dequal: 2.0.3 @@ -3526,6 +3853,11 @@ snapshots: optionalDependencies: svelte: 5.48.0 + svelte-sonner@1.0.7(svelte@5.48.0): + dependencies: + runed: 0.28.0(svelte@5.48.0) + svelte: 5.48.0 + 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): dependencies: clsx: 2.1.1 @@ -3535,6 +3867,13 @@ snapshots: transitivePeerDependencies: - '@sveltejs/kit' + svelte-toolbelt@0.7.1(svelte@5.48.0): + dependencies: + clsx: 2.1.1 + runed: 0.23.4(svelte@5.48.0) + style-to-object: 1.0.14 + svelte: 5.48.0 + svelte@5.48.0: dependencies: '@jridgewell/remapping': 2.3.5 @@ -3555,6 +3894,14 @@ snapshots: tabbable@6.4.0: {} + tailwind-merge@3.5.0: {} + + tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.1.18): + dependencies: + tailwindcss: 4.1.18 + optionalDependencies: + tailwind-merge: 3.5.0 + tailwindcss@4.1.18: {} tapable@2.3.0: {} @@ -3564,6 +3911,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tlds@1.261.0: {} + totalist@3.0.1: {} ts-api-utils@2.4.0(typescript@5.9.3): @@ -3596,6 +3945,10 @@ snapshots: typescript@5.9.3: {} + uint8arrays@3.0.0: + dependencies: + multiformats: 9.9.0 + undici-types@7.16.0: {} undici@7.18.2: {} @@ -3610,6 +3963,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-polyfill@1.1.14: {} + util-deprecate@1.0.2: {} valibot@1.2.0(typescript@5.9.3): @@ -3691,3 +4046,5 @@ snapshots: youch-core: 0.3.3 zimmerframe@1.1.4: {} + + zod@3.25.76: {} diff --git a/scripts/generate-key.ts b/scripts/generate-key.ts deleted file mode 100644 index 223c575..0000000 --- a/scripts/generate-key.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { generateClientAssertionKey } from '@atcute/oauth-node-client'; - -const key = await generateClientAssertionKey('main-key'); -const json = JSON.stringify(key); - -console.log('Generated client assertion key.\n'); -console.log('Set it as a Cloudflare Workers secret:\n'); -console.log(' npx wrangler secret put CLIENT_ASSERTION_KEY\n'); -console.log('Then paste this value:\n'); -console.log(json); diff --git a/src/app.css b/src/app.css index c877e81..b82b42e 100644 --- a/src/app.css +++ b/src/app.css @@ -1,6 +1,7 @@ @import 'tailwindcss'; @plugin '@tailwindcss/forms'; +@source "../node_modules/@foxui"; /* @custom-variant dark (&:where(.dark, .dark *)); */ diff --git a/src/app.d.ts b/src/app.d.ts index 056874b..69b2623 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -10,7 +10,7 @@ declare global { interface Locals { session: OAuthSession | null; client: Client | null; - did: Did | undefined; + did: Did | null; } // interface PageData {} // interface PageState {} @@ -19,6 +19,9 @@ declare global { OAUTH_SESSIONS: KVNamespace; OAUTH_STATES: KVNamespace; CLIENT_ASSERTION_KEY: string; + COOKIE_SECRET: string; + OAUTH_PUBLIC_URL: string; + PROFILE_CACHE?: KVNamespace; }; } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 0b58ab7..98b4fae 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,28 +1,15 @@ import type { Handle } from '@sveltejs/kit'; -import { createOAuthClient } from '$lib/server/oauth'; -import { Client } from '@atcute/client'; -import type { Did } from '@atcute/lexicons'; +import { restoreSession } from '$lib/atproto/server/session'; export const handle: Handle = async ({ event, resolve }) => { - event.locals.session = null; - event.locals.client = null; - event.locals.did = undefined; - - const did = event.cookies.get('did') as Did | undefined; - - if (did) { - try { - const oauth = createOAuthClient(event.platform?.env); - const session = await oauth.restore(did); - - event.locals.session = session; - event.locals.client = new Client({ handler: session }); - event.locals.did = did; - } catch (e) { - console.error('Failed to restore session:', e); - event.cookies.delete('did', { path: '/' }); - } - } + const { session, client, did } = await restoreSession( + event.cookies, + event.platform?.env + ); + + event.locals.session = session; + event.locals.client = client; + event.locals.did = did; return resolve(event); }; diff --git a/src/lib/atproto/UI/Avatar.svelte b/src/lib/atproto/UI/Avatar.svelte deleted file mode 100644 index e754582..0000000 --- a/src/lib/atproto/UI/Avatar.svelte +++ /dev/null @@ -1,66 +0,0 @@ - - -
- {#if fallback} - {fallback} - {:else} - - - - {/if} - {#if src} - {alt { - imageRef?.classList.add('hidden'); - }} - /> - {/if} -
diff --git a/src/lib/atproto/UI/Button.svelte b/src/lib/atproto/UI/Button.svelte deleted file mode 100644 index d333cf1..0000000 --- a/src/lib/atproto/UI/Button.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/src/lib/atproto/UI/HandleInput.svelte b/src/lib/atproto/UI/HandleInput.svelte deleted file mode 100644 index 0b96da5..0000000 --- a/src/lib/atproto/UI/HandleInput.svelte +++ /dev/null @@ -1,90 +0,0 @@ - - - { - if (!o) results = []; - }} - bind:value={ - () => { - return value; - }, - (val) => { - const profile = results.find((v) => v.handle === val); - if (profile) onselected?.(profile); - - value = val; - } - } - bind:open={ - () => { - return open && results.length > 0; - }, - (val) => { - open = val; - } - } -> - { - value = e.currentTarget.value; - search(e.currentTarget.value); - }} - 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" - placeholder="handle" - id="" - aria-label="enter your handle" - /> - - - {#each results as actor (actor.did)} - - {#snippet children()} - - {actor.handle} - {/snippet} - - {/each} - - - diff --git a/src/lib/atproto/UI/LoginModal.svelte b/src/lib/atproto/UI/LoginModal.svelte deleted file mode 100644 index e04b78a..0000000 --- a/src/lib/atproto/UI/LoginModal.svelte +++ /dev/null @@ -1,273 +0,0 @@ - - - - -{#if loginModalState.visible} - -{/if} diff --git a/src/lib/atproto/UI/SecondaryButton.svelte b/src/lib/atproto/UI/SecondaryButton.svelte deleted file mode 100644 index ab2c587..0000000 --- a/src/lib/atproto/UI/SecondaryButton.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/src/lib/atproto/UI/index.ts b/src/lib/atproto/UI/index.ts deleted file mode 100644 index 1745e61..0000000 --- a/src/lib/atproto/UI/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as LoginModal, loginModalState } from './LoginModal.svelte'; diff --git a/src/lib/atproto/auth.svelte.ts b/src/lib/atproto/auth.svelte.ts index 598e0ac..58de137 100644 --- a/src/lib/atproto/auth.svelte.ts +++ b/src/lib/atproto/auth.svelte.ts @@ -4,17 +4,17 @@ import { page } from '$app/state'; export const user = { get profile() { - return page.data?.profile as AppBskyActorDefs.ProfileViewDetailed | undefined; + return (page.data?.profile as AppBskyActorDefs.ProfileViewDetailed | null) ?? null; }, get isLoggedIn() { return !!page.data?.did; }, get did() { - return page.data?.did as Did | undefined; + return (page.data?.did as Did | null) ?? null; } }; -export async function login(handle: ActorIdentifier) { +export async function login(handle: string) { if (handle.startsWith('did:')) { if (handle.length < 6) throw new Error('DID must be at least 6 characters'); } else if (handle.includes('.') && handle.length > 3) { @@ -27,7 +27,7 @@ export async function login(handle: ActorIdentifier) { throw new Error('Please provide a valid handle or DID.'); } - const { oauthLogin } = await import('./oauth.remote'); + const { oauthLogin } = await import('./server/oauth.remote'); const { url } = await oauthLogin({ handle }); window.location.assign(url); @@ -40,7 +40,7 @@ export async function login(handle: ActorIdentifier) { } export async function signup() { - const { oauthLogin } = await import('./oauth.remote'); + const { oauthLogin } = await import('./server/oauth.remote'); const { url } = await oauthLogin({ signup: true }); window.location.assign(url); @@ -53,7 +53,7 @@ export async function signup() { export async function logout() { try { - const { oauthLogout } = await import('./oauth.remote'); + const { oauthLogout } = await import('./server/oauth.remote'); await oauthLogout(); } catch (e) { console.error('Error logging out:', e); diff --git a/src/lib/atproto/index.ts b/src/lib/atproto/index.ts index 14ab812..43b9e59 100644 --- a/src/lib/atproto/index.ts +++ b/src/lib/atproto/index.ts @@ -1,5 +1,4 @@ export { user, login, signup, logout } from './auth.svelte'; -export { metadata } from './metadata'; export { parseUri, @@ -15,5 +14,6 @@ export { describeRepo, getBlobURL, getCDNImageBlobUrl, - searchActorsTypeahead + searchActorsTypeahead, + createTID } from './methods'; diff --git a/src/lib/atproto/metadata.ts b/src/lib/atproto/metadata.ts index 7d5a166..b470f8e 100644 --- a/src/lib/atproto/metadata.ts +++ b/src/lib/atproto/metadata.ts @@ -1,37 +1,24 @@ -import { permissions, REDIRECT_PATH, SITE } from './settings'; +import { permissions } from './settings'; function constructScope() { - const repos = permissions.collections.map((collection) => 'repo:' + collection).join(' '); + const parts: string[] = ['atproto']; + + for (const collection of permissions.collections) { + parts.push('repo:' + collection); + } - let rpcs = ''; for (const [key, value] of Object.entries(permissions.rpc ?? {})) { - if (Array.isArray(value)) { - rpcs += value.map((lxm) => 'rpc?lxm=' + lxm + '&aud=' + key).join(' '); - } else { - rpcs += 'rpc?lxm=' + value + '&aud=' + key; + const lxms = Array.isArray(value) ? value : [value]; + for (const lxm of lxms) { + parts.push('rpc?lxm=' + lxm + '&aud=' + key); } } - let blobScope: string | undefined = undefined; - if (Array.isArray(permissions.blobs) && permissions.blobs.length > 0) { - blobScope = 'blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&'); - } else if (permissions.blobs && permissions.blobs.length > 0) { - blobScope = 'blob:' + permissions.blobs; + if (permissions.blobs.length > 0) { + parts.push('blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&')); } - const scope = ['atproto', repos, rpcs, blobScope].filter((v) => v?.trim()).join(' '); - return scope; + return parts.join(' '); } export const scope = constructScope(); - -export function constructMetadata() { - return { - client_id: SITE + '/oauth-client-metadata.json', - redirect_uris: [SITE + REDIRECT_PATH] as [string], - scope, - jwks_uri: SITE + '/oauth/jwks.json' - }; -} - -export const metadata = constructMetadata(); diff --git a/src/lib/atproto/methods.ts b/src/lib/atproto/methods.ts index 38637bd..acf9470 100644 --- a/src/lib/atproto/methods.ts +++ b/src/lib/atproto/methods.ts @@ -1,6 +1,6 @@ import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons'; import { user } from './auth.svelte'; -import type { AllowedCollection } from './settings'; +import { DOH_RESOLVER, type AllowedCollection } from './settings'; import { CompositeDidDocumentResolver, CompositeHandleResolver, @@ -30,7 +30,7 @@ export function parseUri(uri: string) { export async function resolveHandle({ handle }: { handle: Handle }) { const handleResolver = new CompositeHandleResolver({ methods: { - dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }), + dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }), http: new WellKnownHandleResolver() } }); @@ -64,7 +64,7 @@ export async function getPDS(did: Did) { */ export async function getDetailedProfile(data?: { did?: Did; client?: Client }) { data ??= {}; - data.did ??= user.did; + data.did ??= user.did ?? undefined; if (!data.did) throw new Error('Error getting detailed profile: no did'); @@ -111,12 +111,12 @@ export async function listRecords({ limit?: number; client?: Client; }) { - did ??= user.did; + did ??= user.did ?? undefined; if (!collection) { throw new Error('Missing parameters for listRecords'); } if (!did) { - throw new Error('Missing did for getRecord'); + throw new Error('Missing did for listRecords'); } client ??= await getClient({ did }); @@ -159,7 +159,7 @@ export async function getRecord({ rkey?: string; client?: Client; }) { - did ??= user.did; + did ??= user.did ?? undefined; if (!collection) { throw new Error('Missing parameters for getRecord'); @@ -195,7 +195,7 @@ export async function putRecord({ }) { if (!user.did) throw new Error('Not logged in'); - const { putRecord: putRecordRemote } = await import('./repo.remote'); + const { putRecord: putRecordRemote } = await import('./server/repo.remote'); const data = await putRecordRemote({ collection, rkey, record }); return { ok: true, data }; } @@ -212,7 +212,7 @@ export async function deleteRecord({ }) { if (!user.did) throw new Error('Not logged in'); - const { deleteRecord: deleteRecordRemote } = await import('./repo.remote'); + const { deleteRecord: deleteRecordRemote } = await import('./server/repo.remote'); const data = await deleteRecordRemote({ collection, rkey }); return data.ok; } @@ -223,7 +223,7 @@ export async function deleteRecord({ export async function uploadBlob({ blob }: { blob: Blob }) { if (!user.did) throw new Error("Can't upload blob: Not logged in"); - const { uploadBlob: uploadBlobRemote } = await import('./repo.remote'); + const { uploadBlob: uploadBlobRemote } = await import('./server/repo.remote'); return await uploadBlobRemote({ blob }); } @@ -231,7 +231,7 @@ export async function uploadBlob({ blob }: { blob: Blob }) { * Gets metadata about a repository. */ export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { - did ??= user.did; + did ??= user.did ?? undefined; if (!did) { throw new Error('Error describeRepo: No did'); } @@ -281,7 +281,7 @@ export function getCDNImageBlobUrl({ }; }; }) { - did ??= user.did; + did ??= user.did ?? undefined; return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; } diff --git a/src/lib/atproto/scripts/generate-key.ts b/src/lib/atproto/scripts/generate-key.ts new file mode 100644 index 0000000..3d1cc8a --- /dev/null +++ b/src/lib/atproto/scripts/generate-key.ts @@ -0,0 +1,4 @@ +import { generateClientAssertionKey } from '@atcute/oauth-node-client'; + +const key = await generateClientAssertionKey('main-key'); +console.log(JSON.stringify(key)); diff --git a/src/lib/atproto/scripts/generate-secret.ts b/src/lib/atproto/scripts/generate-secret.ts new file mode 100644 index 0000000..9092066 --- /dev/null +++ b/src/lib/atproto/scripts/generate-secret.ts @@ -0,0 +1,3 @@ +import { randomBytes } from 'node:crypto'; + +console.log(randomBytes(32).toString('base64url')); diff --git a/src/lib/atproto/scripts/setup-dev.ts b/src/lib/atproto/scripts/setup-dev.ts new file mode 100644 index 0000000..24cd144 --- /dev/null +++ b/src/lib/atproto/scripts/setup-dev.ts @@ -0,0 +1,47 @@ +import { existsSync } from 'node:fs'; +import { copyFile, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { randomBytes } from 'node:crypto'; + +import { generateClientAssertionKey } from '@atcute/oauth-node-client'; + +const cwd = process.cwd(); +const examplePath = resolve(cwd, '.env.example'); +const envPath = resolve(cwd, '.env'); + +if (!existsSync(envPath)) { + if (!existsSync(examplePath)) { + throw new Error(`missing .env.example (expected at ${examplePath})`); + } + await copyFile(examplePath, envPath); + console.log(`created ${envPath}`); +} + +const upsertVar = (input: string, key: string, value: string): string => { + const line = `${key}=${value}`; + const re = new RegExp(`^${key}=.*$`, 'm'); + + if (re.test(input)) { + const match = input.match(re); + const current = match ? match[0].slice(key.length + 1).trim() : ''; + // Only overwrite if empty/placeholder + if (current === '' || current === "''" || current === '""' || current.includes('...')) { + return input.replace(re, line); + } + return input; + } + + const suffix = input.endsWith('\n') || input.length === 0 ? '' : '\n'; + return `${input}${suffix}${line}\n`; +}; + +let vars = await readFile(envPath, 'utf8'); + +const secret = randomBytes(32).toString('base64url'); +vars = upsertVar(vars, 'COOKIE_SECRET', secret); + +const jwk = await generateClientAssertionKey('main-key'); +vars = upsertVar(vars, 'CLIENT_ASSERTION_KEY', JSON.stringify(jwk)); + +await writeFile(envPath, vars); +console.log(`updated ${envPath}`); diff --git a/src/lib/server/kv-store.ts b/src/lib/atproto/server/kv-store.ts similarity index 100% rename from src/lib/server/kv-store.ts rename to src/lib/atproto/server/kv-store.ts diff --git a/src/lib/atproto/oauth.remote.ts b/src/lib/atproto/server/oauth.remote.ts similarity index 85% rename from src/lib/atproto/oauth.remote.ts rename to src/lib/atproto/server/oauth.remote.ts index 22546c0..a3ca1cd 100644 --- a/src/lib/atproto/oauth.remote.ts +++ b/src/lib/atproto/server/oauth.remote.ts @@ -1,9 +1,10 @@ import * as v from 'valibot'; import { error } from '@sveltejs/kit'; import { command, getRequestEvent } from '$app/server'; -import { createOAuthClient } from '$lib/server/oauth'; -import { scope } from '$lib/atproto/metadata'; -import { signUpPDS } from '$lib/atproto/settings'; +import { createOAuthClient } from './oauth'; +import { getSignedCookie } from './signed-cookie'; +import { scope } from '../metadata'; +import { signUpPDS } from '../settings'; import type { ActorIdentifier, Did } from '@atcute/lexicons'; export const oauthLogin = command( @@ -38,7 +39,7 @@ export const oauthLogin = command( export const oauthLogout = command(async () => { const { cookies, platform } = getRequestEvent(); - const did = cookies.get('did') as Did | undefined; + const did = getSignedCookie(cookies, 'did') as Did | null; if (did) { try { diff --git a/src/lib/server/oauth.ts b/src/lib/atproto/server/oauth.ts similarity index 78% rename from src/lib/server/oauth.ts rename to src/lib/atproto/server/oauth.ts index 8d0818d..179ff04 100644 --- a/src/lib/server/oauth.ts +++ b/src/lib/atproto/server/oauth.ts @@ -18,8 +18,8 @@ import { WellKnownHandleResolver } from '@atcute/identity-resolver'; import { KVStore } from './kv-store'; -import { DOH_RESOLVER, REDIRECT_PATH, SITE } from '$lib/atproto/settings'; -import { scope } from '$lib/atproto/metadata'; +import { DOH_RESOLVER, REDIRECT_PATH } from '../settings'; +import { scope } from '../metadata'; import { dev } from '$app/environment'; function createActorResolver() { @@ -57,8 +57,8 @@ export function createOAuthClient(env?: App.Platform['env']): OAuthClient { const actorResolver = createActorResolver(); const stores = createStores(env); - if (dev) { - // In development, use loopback client (public, no keyset). + if (dev && !env?.OAUTH_PUBLIC_URL) { + // Dev without tunnel: loopback public client (no keyset). // Omit client_id — the library builds it automatically from redirect_uris + scope. // redirect_uris must use 127.0.0.1 (not localhost). return new OAuthClient({ @@ -71,18 +71,22 @@ export function createOAuthClient(env?: App.Platform['env']): OAuthClient { }); } - // In production, use confidential client with keyset - if (!env?.CLIENT_ASSERTION_KEY) { - throw new Error('CLIENT_ASSERTION_KEY secret is not set. Run: pnpm generate-key && npx wrangler secret put CLIENT_ASSERTION_KEY'); + // Confidential client (production, or dev with tunnel via OAUTH_PUBLIC_URL) + if (!env?.OAUTH_PUBLIC_URL) { + throw new Error('OAUTH_PUBLIC_URL is not set'); } + if (!env.CLIENT_ASSERTION_KEY) { + throw new Error('CLIENT_ASSERTION_KEY secret is not set. Run: pnpm env:generate-key'); + } + const site = env.OAUTH_PUBLIC_URL; const key: ClientAssertionPrivateJwk = JSON.parse(env.CLIENT_ASSERTION_KEY); return new OAuthClient({ metadata: { - client_id: SITE + '/oauth-client-metadata.json', - redirect_uris: [SITE + REDIRECT_PATH], + client_id: site + '/oauth-client-metadata.json', + redirect_uris: [site + REDIRECT_PATH], scope, - jwks_uri: SITE + '/oauth/jwks.json' + jwks_uri: site + '/oauth/jwks.json' }, keyset: [key], actorResolver, diff --git a/src/lib/atproto/server/profile.ts b/src/lib/atproto/server/profile.ts new file mode 100644 index 0000000..20521fc --- /dev/null +++ b/src/lib/atproto/server/profile.ts @@ -0,0 +1,51 @@ +import type { Did } from '@atcute/lexicons'; +import { getDetailedProfile, describeRepo } from '../methods'; + +const PROFILE_CACHE_TTL = 60 * 60; // 1 hour + +/** + * Loads a user's profile, with optional KV caching. + * Falls back to a fresh fetch if the cache KV doesn't exist or on cache miss. + * Returns undefined if the profile can't be loaded. + */ +export async function loadProfile(did: Did, profileCache?: KVNamespace) { + // Try cache first + if (profileCache) { + try { + const cached = await profileCache.get(did, 'json'); + if (cached) return cached as Record; + } catch { + // Cache read failed, continue to fresh fetch + } + } + + const profile = await fetchProfile(did); + + // Write to cache (fire-and-forget) + if (profileCache && profile) { + profileCache + .put(did, JSON.stringify(profile), { expirationTtl: PROFILE_CACHE_TTL }) + .catch(() => {}); + } + + return profile; +} + +async function fetchProfile(did: Did) { + try { + let profile = await getDetailedProfile({ did }); + + if (!profile || profile.handle === 'handle.invalid') { + const repo = await describeRepo({ did }); + profile = { + did, + handle: repo?.handle || 'handle.invalid' + } as typeof profile; + } + + return profile; + } catch (e) { + console.error('Failed to load profile:', e); + return undefined; + } +} diff --git a/src/lib/atproto/repo.remote.ts b/src/lib/atproto/server/repo.remote.ts similarity index 75% rename from src/lib/atproto/repo.remote.ts rename to src/lib/atproto/server/repo.remote.ts index fc9d15a..6af60d8 100644 --- a/src/lib/atproto/repo.remote.ts +++ b/src/lib/atproto/server/repo.remote.ts @@ -1,16 +1,25 @@ import { error } from '@sveltejs/kit'; import { command, getRequestEvent } from '$app/server'; import * as v from 'valibot'; +import { permissions } from '../settings'; +// Validate collection format and check against allowed list from settings const collectionSchema = v.pipe( v.string(), - v.regex(/^[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*$/) + v.regex(/^[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*$/), + v.check( + (c) => permissions.collections.some((allowed) => c === allowed || allowed.startsWith(c + '?')), + 'Collection not in allowed list' + ) ); +// AT Protocol rkey: TID, 'self', or other valid record keys (alphanumeric, dash, underscore, dot) +const rkeySchema = v.optional(v.pipe(v.string(), v.regex(/^[a-zA-Z0-9._:~-]{1,512}$/))); + export const putRecord = command( v.object({ collection: collectionSchema, - rkey: v.optional(v.string()), + rkey: rkeySchema, record: v.record(v.string(), v.unknown()) }), async (input) => { @@ -33,7 +42,7 @@ export const putRecord = command( export const deleteRecord = command( v.object({ collection: collectionSchema, - rkey: v.optional(v.string()) + rkey: rkeySchema }), async (input) => { const { locals } = getRequestEvent(); @@ -64,7 +73,7 @@ export const uploadBlob = command( input: input.blob }); - if (!response?.ok) error(500, 'Upload failed'); + if (!response.ok) error(500, 'Upload failed'); return response.data.blob as { $type: 'blob'; diff --git a/src/lib/atproto/server/session.ts b/src/lib/atproto/server/session.ts new file mode 100644 index 0000000..c0b27ae --- /dev/null +++ b/src/lib/atproto/server/session.ts @@ -0,0 +1,43 @@ +import type { Cookies } from '@sveltejs/kit'; +import { Client } from '@atcute/client'; +import type { Did } from '@atcute/lexicons'; +import type { OAuthSession } from '@atcute/oauth-node-client'; +import { createOAuthClient } from './oauth'; +import { getSignedCookie } from './signed-cookie'; + +export type SessionLocals = { + session: OAuthSession | null; + client: Client | null; + did: Did | null; +}; + +/** + * Restores an OAuth session from the signed `did` cookie. + * Returns session locals to be assigned to `event.locals`. + * Deletes the cookie if the session can't be restored. + */ +export async function restoreSession( + cookies: Cookies, + env?: App.Platform['env'] +): Promise { + const did = getSignedCookie(cookies, 'did') as Did | null; + + if (!did) { + return { session: null, client: null, did: null }; + } + + try { + const oauth = createOAuthClient(env); + const session = await oauth.restore(did); + + return { + session, + client: new Client({ handler: session }), + did + }; + } catch (e) { + console.error('Failed to restore session:', e); + cookies.delete('did', { path: '/' }); + return { session: null, client: null, did: null }; + } +} diff --git a/src/lib/atproto/server/signed-cookie.ts b/src/lib/atproto/server/signed-cookie.ts new file mode 100644 index 0000000..6cde19c --- /dev/null +++ b/src/lib/atproto/server/signed-cookie.ts @@ -0,0 +1,69 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import type { Cookies } from '@sveltejs/kit'; + +import { env } from '$env/dynamic/private'; +import { dev } from '$app/environment'; + +const SEPARATOR = '.'; + +function getSecret(): string { + const secret = env.COOKIE_SECRET; + if (secret) return secret; + if (dev) return 'dev-cookie-secret-not-for-production'; + throw new Error('COOKIE_SECRET is not set'); +} + +function toBase64Url(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function fromBase64Url(str: string): Uint8Array { + const padded = str + '='.repeat((4 - (str.length % 4)) % 4); + const base64 = padded.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +function hmacSha256(data: string): Uint8Array { + return createHmac('sha256', getSecret()).update(data).digest(); +} + +export function getSignedCookie(cookies: Cookies, name: string): string | null { + const signed = cookies.get(name); + if (!signed) return null; + + const idx = signed.lastIndexOf(SEPARATOR); + if (idx === -1) return null; + + const value = signed.slice(0, idx); + const sig = signed.slice(idx + 1); + + let expected: Uint8Array; + let got: Uint8Array; + try { + expected = hmacSha256(value); + got = fromBase64Url(sig); + } catch { + return null; + } + + if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null; + + return value; +} + +export function setSignedCookie( + cookies: Cookies, + name: string, + value: string, + options: Parameters[2] +): void { + const sig = toBase64Url(hmacSha256(value)); + const signed = `${value}${SEPARATOR}${sig}`; + cookies.set(name, signed, options); +} diff --git a/src/lib/atproto/settings.ts b/src/lib/atproto/settings.ts index 273d1dd..5ad4549 100644 --- a/src/lib/atproto/settings.ts +++ b/src/lib/atproto/settings.ts @@ -1,9 +1,5 @@ import { dev } from '$app/environment'; -export const SITE = dev - ? 'http://localhost:5183' - : 'https://svelte-atproto-oauth-cloudflare-workers.flobit-dev.workers.dev'; - type Permissions = { collections: readonly string[]; rpc: Record; @@ -26,7 +22,7 @@ type ExtractCollectionBase = T extends `${infer Base}?${string export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>; -// which PDS to use for signup +// which PDS to use for signup (change to your preferred PDS) const devPDS = 'https://pds.rip/'; const prodPDS = 'https://selfhosted.social/'; export const signUpPDS = dev ? devPDS : prodPDS; diff --git a/src/routes/oauth-client-metadata.json/+server.ts b/src/routes/(oauth)/oauth-client-metadata.json/+server.ts similarity index 78% rename from src/routes/oauth-client-metadata.json/+server.ts rename to src/routes/(oauth)/oauth-client-metadata.json/+server.ts index 0f4703e..44794e4 100644 --- a/src/routes/oauth-client-metadata.json/+server.ts +++ b/src/routes/(oauth)/oauth-client-metadata.json/+server.ts @@ -1,5 +1,5 @@ import { json } from '@sveltejs/kit'; -import { createOAuthClient } from '$lib/server/oauth'; +import { createOAuthClient } from '$lib/atproto/server/oauth'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = async ({ platform }) => { diff --git a/src/routes/(oauth)/oauth/callback/+server.ts b/src/routes/(oauth)/oauth/callback/+server.ts new file mode 100644 index 0000000..d55f1a9 --- /dev/null +++ b/src/routes/(oauth)/oauth/callback/+server.ts @@ -0,0 +1,28 @@ +import { redirect } from '@sveltejs/kit'; +import { createOAuthClient } from '$lib/atproto/server/oauth'; +import { setSignedCookie } from '$lib/atproto/server/signed-cookie'; +import { dev } from '$app/environment'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, platform, cookies }) => { + const oauth = createOAuthClient(platform?.env); + + // oauth.callback() validates the state parameter (CSRF protection) and + // exchanges the authorization code for tokens via the token endpoint. + try { + const { session } = await oauth.callback(url.searchParams); + + setSignedCookie(cookies, 'did', session.did, { + path: '/', + httpOnly: true, + secure: !dev, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 180 // 180 days + }); + } catch (e) { + console.error('OAuth callback failed:', e); + redirect(303, '/?error=auth_failed'); + } + + redirect(303, '/'); +}; diff --git a/src/routes/oauth/jwks.json/+server.ts b/src/routes/(oauth)/oauth/jwks.json/+server.ts similarity index 79% rename from src/routes/oauth/jwks.json/+server.ts rename to src/routes/(oauth)/oauth/jwks.json/+server.ts index d03078f..ae4dd84 100644 --- a/src/routes/oauth/jwks.json/+server.ts +++ b/src/routes/(oauth)/oauth/jwks.json/+server.ts @@ -1,5 +1,5 @@ import { json } from '@sveltejs/kit'; -import { createOAuthClient } from '$lib/server/oauth'; +import { createOAuthClient } from '$lib/atproto/server/oauth'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = async ({ platform }) => { diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index b9e55ae..0fb3c26 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,25 +1,12 @@ import type { LayoutServerLoad } from './$types'; -import { getDetailedProfile, describeRepo } from '$lib/atproto/methods'; +import { loadProfile } from '$lib/atproto/server/profile'; -export const load: LayoutServerLoad = async ({ locals }) => { +export const load: LayoutServerLoad = async ({ locals, platform }) => { if (!locals.did || !locals.client) { - return { did: undefined, profile: undefined }; + return { did: null, profile: null }; } - let profile; - try { - profile = await getDetailedProfile({ did: locals.did }); - - if (!profile || profile.handle === 'handle.invalid') { - const repo = await describeRepo({ did: locals.did }); - profile = { - did: locals.did, - handle: repo?.handle || 'handle.invalid' - }; - } - } catch (e) { - console.error('Failed to load profile:', e); - } + const profile = await loadProfile(locals.did, platform?.env?.PROFILE_CACHE); return { did: locals.did, diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 756554e..f252e53 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,11 +1,20 @@ {@render children()} - + { + await login(handle); + return true; + }} + signup={async () => { + signup(); + return true; + }} +/> \ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 50c2696..423ed30 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,13 +1,14 @@ @@ -23,7 +24,7 @@ {#if !user.isLoggedIn}
not logged in
- + {/if} {#if user.isLoggedIn} @@ -36,32 +37,36 @@
Statusphere test: -
- {#each emojis as emoji (emoji)} - - {/each} -
- {#if data.statuses.length > 0} -
Recent statuses:
-
- {#each data.statuses as status (status.rkey)} - {status.status} - {/each} -
- {/if} + { + await putRecord({ + rkey: createTID(), + collection: 'xyz.statusphere.status', + record: { + status: emoji.unicode, + createdAt: new Date() + } + }); + await invalidateAll(); + }} + /> + {#if data.statuses.length > 0} +
Recent statuses:
+
    + {#each data.statuses as status, i (status.rkey)} +
  • + {#if i === 0} + {status.status} + {:else} + {status.status} + {/if} + + + +
  • + {/each} +
+ {/if}
diff --git a/src/routes/oauth/callback/+server.ts b/src/routes/oauth/callback/+server.ts deleted file mode 100644 index 4d8bf91..0000000 --- a/src/routes/oauth/callback/+server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import { createOAuthClient } from '$lib/server/oauth'; -import { dev } from '$app/environment'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ url, platform, cookies }) => { - const oauth = createOAuthClient(platform?.env); - - const { session } = await oauth.callback(url.searchParams); - - cookies.set('did', session.did, { - path: '/', - httpOnly: true, - secure: !dev, - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 180 // 180 days - }); - - redirect(303, '/'); -}; diff --git a/wrangler.jsonc b/wrangler.jsonc index d6b20dc..8b92e44 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,13 +1,9 @@ -/** - * For more details on how to configure Wrangler, refer to: - * https://developers.cloudflare.com/workers/wrangler/configuration/ - */ { "$schema": "node_modules/wrangler/config-schema.json", "name": "svelte-atproto-oauth-cloudflare-workers", "main": ".svelte-kit/cloudflare/_worker.js", "compatibility_date": "2025-12-25", - "compatibility_flags": ["nodejs_als"], + "compatibility_flags": ["nodejs_compat_v2"], "assets": { "binding": "ASSETS", "directory": ".svelte-kit/cloudflare" @@ -15,24 +11,9 @@ "observability": { "enabled": true }, - /** - * Smart Placement - * https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement - */ - // "placement": { "mode": "smart" } - /** - * Bindings - * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including - * databases, object storage, AI inference, real-time communication and more. - * https://developers.cloudflare.com/workers/runtime-apis/bindings/ - */ - /** - * Environment Variables - * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables - * Note: Use secrets to store sensitive data. - * https://developers.cloudflare.com/workers/configuration/secrets/ - */ - "vars": {}, + "vars": { + "OAUTH_PUBLIC_URL": "https://svelte-atproto-oauth-cloudflare-workers.flobit-dev.workers.dev" + }, "kv_namespaces": [ { "binding": "OAUTH_SESSIONS", @@ -43,10 +24,4 @@ "id": "115d8089d9ca4592a3a5e4a7d9146610" } ] - - /** - * Service Bindings (communicate between multiple Workers) - * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings - */ - // "services": [ { "binding": "MY_SERVICE", "service": "my-service" } ] }