From 01d8339339d609698d2a67452fdadf95d8260c2b Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Mon, 2 Mar 2026 02:02:40 +0100 Subject: [PATCH] update agents setup --- AGENT_SETUP.md | 1100 +--------------------------------- AGENT_SETUP_REFERENCE.md | 469 --------------- package.json | 1 - scripts/build-agent-setup.ts | 45 -- 4 files changed, 28 insertions(+), 1587 deletions(-) delete mode 100644 AGENT_SETUP_REFERENCE.md delete mode 100644 scripts/build-agent-setup.ts diff --git a/AGENT_SETUP.md b/AGENT_SETUP.md index cf23d35..0466f5a 100644 --- a/AGENT_SETUP.md +++ b/AGENT_SETUP.md @@ -47,11 +47,13 @@ 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)/`. +Fetch each file from the URL and write it to the specified path. All files go into `src/lib/atproto/` and `src/routes/(oauth)/`. + +**Base URL:** `https://raw.githubusercontent.com/flo-bit/atproto-oauth-cloudflare/main/` ### `src/lib/atproto/settings.ts` -Fill in `collections` from the user's answers. If no collections were specified, use an empty array. Build `scopes` using `scope` builders from `@atcute/oauth-node-client` — add `scope.blob()`, `scope.rpc()`, etc. as needed. +**Do not fetch this file.** Create it manually using the template below, customized with the user's answers: ```ts import { dev } from '$app/environment'; @@ -81,1076 +83,30 @@ export const REDIRECT_TO_LAST_PAGE_ON_LOGIN = true; export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query'; ``` -### `src/lib/atproto/auth.svelte.ts` - -```ts -import { AppBskyActorDefs } from '@atcute/bluesky'; -import type { ActorIdentifier, Did } from '@atcute/lexicons'; -import { page } from '$app/state'; -import { ALLOW_SIGNUP, REDIRECT_TO_LAST_PAGE_ON_LOGIN } from './settings'; - -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; - } -}; - -function saveReturnTo() { - if (REDIRECT_TO_LAST_PAGE_ON_LOGIN) { - document.cookie = `oauth_return_to=${encodeURIComponent(window.location.pathname + window.location.search)};path=/;max-age=600;samesite=lax`; - } -} - -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 }); - saveReturnTo(); - window.location.assign(url); - - // Wait for navigation (prevents UI flash) - await new Promise((_resolve, reject) => { - window.addEventListener('pageshow', () => reject(new Error('user aborted the login request')), { - once: true - }); - }); -} - -export async function signup() { - if (!ALLOW_SIGNUP) throw new Error('Signup is not enabled'); - - const { oauthLogin } = await import('./server/oauth.remote'); - const { url } = await oauthLogin({ signup: true }); - saveReturnTo(); - 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); - } - - // Full reload to clear server session state - window.location.href = '/'; -} -``` - -### `src/lib/atproto/methods.ts` - -```ts -import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons'; -import { isDid } from '@atcute/lexicons/syntax'; -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'; - -/** - * Parses an AT Protocol URI into its components. - */ -export function parseUri(uri: string) { - const parts = parseResourceUri(uri); - if (!parts.ok) return; - return parts.value; -} - -/** - * Resolves a handle to a DID using DNS and HTTP methods. - */ -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; -} - -/** - * Returns a DID given a handle or DID string. - */ -export async function actorToDid(actor: string): Promise { - if (isDid(actor)) return actor; - return await resolveHandle({ handle: actor as Handle }); -} - -const didResolver = new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver() - } -}); - -/** - * Gets the PDS (Personal Data Server) URL for a given DID. - */ -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(); - } - } -} - -/** - * Fetches a detailed Bluesky profile for a user. - */ -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; -} - -/** - * Creates an AT Protocol client for a user's PDS. - */ -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; -} - -/** - * Lists records from a repository collection with pagination support. - */ -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; -} - -/** - * Fetches a single record from a repository. - */ -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)); -} - -/** - * Creates or updates a record via remote function. - */ -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 }; -} - -/** - * Deletes a record via remote function. - */ -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; -} - -/** - * Uploads a blob via remote function. - */ -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 }); -} - -/** - * Gets metadata about a repository. - */ -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; -} - -/** - * Constructs a URL to fetch a blob directly from a user's PDS. - */ -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}`; -} - -/** - * Constructs a Bluesky CDN URL for an image blob. - */ -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`; -} - -/** - * Searches for actors with typeahead/autocomplete functionality. - */ -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 }; -} - -/** - * Return a TID based on current time - */ -export function createTID() { - return TID.now(); -} -``` - -### `src/lib/atproto/index.ts` - -```ts -export { user, login, signup, logout } from './auth.svelte'; - -export { - parseUri, - resolveHandle, - actorToDid, - 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, scopes } from '../settings'; -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 }) - }; - } - // Fallback to in-memory stores (dev without wrangler) - 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) { - // 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({ - metadata: { - redirect_uris: [`http://127.0.0.1:5183${REDIRECT_PATH}`], - scope: scopes - }, - actorResolver, - stores - }); - } - - // 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], - scope: scopes, - 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 { scopes, 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: scopes.join(' '), - prompt: input.signup ? 'create' : undefined - }); - - return { url: url.toString() }; - } catch (e) { - if (e && typeof e === 'object' && 'status' in e) throw e; // re-throw SvelteKit errors - 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: '/' }); - cookies.delete('scope', { 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 { collections } 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-]*){2,}$/), - v.check((c) => collections.includes(c as (typeof collections)[number]), '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: 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'; -import { scopes } from '../settings'; - -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 }; - } - - // If permissions changed since login, invalidate the session - const savedScope = getSignedCookie(cookies, 'scope'); - if (savedScope !== null && savedScope !== scopes.join(' ')) { - cookies.delete('did', { path: '/' }); - cookies.delete('scope', { path: '/' }); - 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: '/' }); - cookies.delete('scope', { 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 - -/** - * 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; - } -} -``` - -### `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() : ''; - // 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}`); -``` - -### `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 { scopes } from '$lib/atproto/settings'; -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); - - const cookieOpts = { - path: '/', - httpOnly: true, - secure: !dev, - sameSite: 'lax' as const, - maxAge: 60 * 60 * 24 * 180 // 180 days - }; - - setSignedCookie(cookies, 'did', session.did, cookieOpts); - setSignedCookie(cookies, 'scope', scopes.join(' '), cookieOpts); - } catch (e) { - console.error('OAuth callback failed:', e); - redirect(303, '/?error=auth_failed'); - } - - const returnTo = cookies.get('oauth_return_to'); - if (returnTo) { - cookies.delete('oauth_return_to', { path: '/' }); - const decoded = decodeURIComponent(returnTo); - if (decoded.startsWith('/') && !decoded.startsWith('//')) { - redirect(303, decoded); - } - } - - 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 -``` +### Files to fetch + +Fetch each file from `{BASE_URL}{path}` and write it to the same path in the project: + +| Path | Description | +|------|-------------| +| `src/lib/atproto/auth.svelte.ts` | Client-side login/signup/logout + user state | +| `src/lib/atproto/methods.ts` | AT Protocol helpers (resolve, read records, write, etc.) | +| `src/lib/atproto/image-helper.ts` | Image compression + upload helpers | +| `src/lib/atproto/index.ts` | Re-exports (remove `signup` if `ALLOW_SIGNUP = false`) | +| `src/lib/atproto/server/signed-cookie.ts` | HMAC-signed cookie helpers | +| `src/lib/atproto/server/kv-store.ts` | KV-backed Store implementation | +| `src/lib/atproto/server/oauth.ts` | OAuthClient factory (dev vs prod) | +| `src/lib/atproto/server/oauth.remote.ts` | Login/logout remote functions | +| `src/lib/atproto/server/repo.remote.ts` | putRecord/deleteRecord/uploadBlob remote functions | +| `src/lib/atproto/server/session.ts` | Session restoration + scope invalidation | +| `src/lib/atproto/server/profile.ts` | Profile loading with optional KV cache | +| `src/lib/atproto/scripts/generate-key.ts` | Generate client assertion key | +| `src/lib/atproto/scripts/generate-secret.ts` | Generate cookie secret | +| `src/lib/atproto/scripts/setup-dev.ts` | Dev environment setup script | +| `src/routes/(oauth)/oauth/callback/+server.ts` | OAuth callback handler | +| `src/routes/(oauth)/oauth/jwks.json/+server.ts` | JWKS endpoint | +| `src/routes/(oauth)/oauth-client-metadata.json/+server.ts` | Client metadata endpoint | +| `.env.example` | Environment variable template | ## Step 3: Modify existing files diff --git a/AGENT_SETUP_REFERENCE.md b/AGENT_SETUP_REFERENCE.md deleted file mode 100644 index 7262ed7..0000000 --- a/AGENT_SETUP_REFERENCE.md +++ /dev/null @@ -1,469 +0,0 @@ -# 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/*`) - -4. **Signup**: Should the app allow users to create new AT Protocol accounts (signup)? - - **`yes`** — Include a signup button/flow - - **`no`** — Login only, no account creation - -5. **Production PDS**: Which PDS should be used for signup in production? (default: `https://selfhosted.social/`) - - Only relevant if signup is enabled. Skip if signup is `no`. - -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` from the user's answers. If no collections were specified, use an empty array. Build `scopes` using `scope` builders from `@atcute/oauth-node-client` — add `scope.blob()`, `scope.rpc()`, etc. as needed. - -```ts -import { dev } from '$app/environment'; -import { scope } from '@atcute/oauth-node-client'; - -// CUSTOMIZE: writable collections -export const collections = [] as const; - -export type AllowedCollection = (typeof collections)[number]; - -// CUSTOMIZE: OAuth scope — add scope.blob({ accept: ['image/*'] }), scope.rpc(), etc. as needed -export const scopes = ['atproto', scope.repo({ collection: [...collections] })]; - -// CUSTOMIZE: set to true to allow signup, false for login-only -export const ALLOW_SIGNUP = true; - -// CUSTOMIZE: PDS to use for signup (only relevant if ALLOW_SIGNUP is true) -const devPDS = 'https://bsky.social/'; -const prodPDS = 'https://selfhosted.social/'; // CUSTOMIZE: change to preferred production PDS -export const signUpPDS = dev ? devPDS : prodPDS; - -export const REDIRECT_PATH = '/oauth/callback'; - -// redirect the user back to the page they were on before login -export const REDIRECT_TO_LAST_PAGE_ON_LOGIN = true; - -export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query'; -``` - -### `src/lib/atproto/auth.svelte.ts` - - - -### `src/lib/atproto/methods.ts` - - - -### `src/lib/atproto/index.ts` - - - -### `src/lib/atproto/server/signed-cookie.ts` - - - -### `src/lib/atproto/server/kv-store.ts` - - - -### `src/lib/atproto/server/oauth.ts` - - - -### `src/lib/atproto/server/oauth.remote.ts` - - - -### `src/lib/atproto/server/repo.remote.ts` - - - -### `src/lib/atproto/server/session.ts` - - - -### `src/lib/atproto/server/profile.ts` - - - -### `src/lib/atproto/scripts/generate-key.ts` - - - -### `src/lib/atproto/scripts/generate-secret.ts` - - - -### `src/lib/atproto/scripts/setup-dev.ts` - - - -### `src/routes/(oauth)/oauth/callback/+server.ts` - - - -### `src/routes/(oauth)/oauth/jwks.json/+server.ts` - - - -### `src/routes/(oauth)/oauth-client-metadata.json/+server.ts` - - - -### `.env.example` - - - -## 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. - -If signup is enabled (`ALLOW_SIGNUP = true`): - -```svelte - - - - { - await login(handle); - return true; - }} - signup={async () => { - signup(); - return true; - }} -/> -``` - -If signup is disabled (`ALLOW_SIGNUP = false`), omit the `signup` prop: - -```svelte - - - { - await login(handle); - 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) -- Do NOT add `OAUTH_PUBLIC_URL` to vars — it is only needed for production deployment and the user will set it themselves later. In dev mode without it, the app uses a loopback client automatically. -- 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/package.json b/package.json index 7a38b9e..34ed9da 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "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", - "build:agent-setup": "npx tsx scripts/build-agent-setup.ts", "tunnel": "cloudflared tunnel --url http://localhost:5183" }, "devDependencies": { diff --git a/scripts/build-agent-setup.ts b/scripts/build-agent-setup.ts deleted file mode 100644 index 28ee750..0000000 --- a/scripts/build-agent-setup.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises'; -import { resolve, extname } from 'node:path'; - -const cwd = process.cwd(); -const referencePath = resolve(cwd, 'AGENT_SETUP_REFERENCE.md'); -const outputPath = resolve(cwd, 'AGENT_SETUP.md'); - -const LANG_MAP: Record = { - '.ts': 'ts', - '.js': 'js', - '.svelte': 'svelte', - '.json': 'json', - '.jsonc': 'jsonc' -}; - -let content = await readFile(referencePath, 'utf8'); - -const pattern = /^$/gm; -let match; -const replacements: { full: string; filePath: string }[] = []; - -while ((match = pattern.exec(content)) !== null) { - replacements.push({ full: match[0], filePath: match[1] }); -} - -for (const { full, filePath } of replacements) { - const absPath = resolve(cwd, filePath); - let fileContent: string; - try { - fileContent = (await readFile(absPath, 'utf8')).trimEnd(); - } catch { - console.error(`ERROR: file not found: ${filePath}`); - process.exit(1); - } - - const ext = extname(filePath); - const lang = LANG_MAP[ext] ?? ''; - - const codeBlock = '```' + lang + '\n' + fileContent + '\n```'; - // Use function replacer to avoid $` and $' special patterns in String.replace() - content = content.replace(full, () => codeBlock); -} - -await writeFile(outputPath, content); -console.log(`built ${outputPath} (${replacements.length} files inlined)`); -- 2.51.2