ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
16
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: move search and follow db tables to kesley, add hono search endpt

byarielm.fyi eeaca6df eb4ce58a

verified
+970
+1
packages/api/src/middleware/index.ts
··· 1 1 export * from "./error"; 2 2 export * from "./auth"; 3 + export * from "./rateLimit";
+130
packages/api/src/middleware/rateLimit.ts
··· 1 + /** 2 + * Rate Limiting Middleware 3 + * Protects API endpoints from abuse 4 + */ 5 + 6 + import { Context } from 'hono'; 7 + import { createMiddleware } from 'hono/factory'; 8 + 9 + interface RateLimitStore { 10 + [key: string]: { 11 + count: number; 12 + resetTime: number; 13 + }; 14 + } 15 + 16 + const store: RateLimitStore = {}; 17 + 18 + interface RateLimitOptions { 19 + maxRequests: number; 20 + windowMs: number; 21 + keyGenerator?: (c: Context) => string; 22 + message?: string; 23 + } 24 + 25 + /** 26 + * Create a rate limit middleware 27 + * 28 + * @param options - Rate limit configuration 29 + * @returns Hono middleware function 30 + * 31 + * @example 32 + * ```ts 33 + * // 10 requests per minute 34 + * const searchLimit = rateLimiter({ 35 + * maxRequests: 10, 36 + * windowMs: 60 * 1000, 37 + * message: 'Too many search requests. Please try again later.' 38 + * }); 39 + * 40 + * app.post('/search', searchLimit, handler); 41 + * ``` 42 + */ 43 + export function rateLimiter(options: RateLimitOptions) { 44 + const { 45 + maxRequests, 46 + windowMs, 47 + keyGenerator = (c) => { 48 + // Default: use IP address or session ID 49 + const sessionId = c.get('sessionId'); 50 + const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown'; 51 + return sessionId || ip; 52 + }, 53 + message = 'Too many requests. Please try again later.', 54 + } = options; 55 + 56 + return createMiddleware(async (c, next) => { 57 + const key = keyGenerator(c); 58 + const now = Date.now(); 59 + 60 + // Clean up expired entries periodically 61 + if (Math.random() < 0.01) { 62 + Object.keys(store).forEach((k) => { 63 + if (store[k].resetTime < now) { 64 + delete store[k]; 65 + } 66 + }); 67 + } 68 + 69 + // Initialize or get existing entry 70 + if (!store[key] || store[key].resetTime < now) { 71 + store[key] = { 72 + count: 0, 73 + resetTime: now + windowMs, 74 + }; 75 + } 76 + 77 + // Increment count 78 + store[key].count++; 79 + 80 + // Set rate limit headers 81 + const remaining = Math.max(0, maxRequests - store[key].count); 82 + const resetTime = Math.ceil(store[key].resetTime / 1000); 83 + 84 + c.header('X-RateLimit-Limit', maxRequests.toString()); 85 + c.header('X-RateLimit-Remaining', remaining.toString()); 86 + c.header('X-RateLimit-Reset', resetTime.toString()); 87 + 88 + // Check if limit exceeded 89 + if (store[key].count > maxRequests) { 90 + const retryAfter = Math.ceil((store[key].resetTime - now) / 1000); 91 + c.header('Retry-After', retryAfter.toString()); 92 + 93 + return c.json( 94 + { 95 + success: false, 96 + error: message, 97 + retryAfter, 98 + }, 99 + 429, 100 + ); 101 + } 102 + 103 + await next(); 104 + }); 105 + } 106 + 107 + /** 108 + * Predefined rate limiters for common use cases 109 + */ 110 + 111 + // General API rate limit: 60 requests per minute 112 + export const apiRateLimit = rateLimiter({ 113 + maxRequests: 60, 114 + windowMs: 60 * 1000, 115 + message: 'Too many requests. Please try again later.', 116 + }); 117 + 118 + // Search rate limit: 10 searches per minute (each can have 50 usernames) 119 + export const searchRateLimit = rateLimiter({ 120 + maxRequests: 10, 121 + windowMs: 60 * 1000, 122 + message: 'Search limit reached. Please wait before searching again.', 123 + }); 124 + 125 + // Follow rate limit: 100 follows per hour 126 + export const followRateLimit = rateLimiter({ 127 + maxRequests: 100, 128 + windowMs: 60 * 60 * 1000, 129 + message: 'Follow limit reached. Please try again later.', 130 + });
+53
packages/api/src/repositories/BaseRepository.ts
··· 1 + /** 2 + * Base Repository for Kysely 3 + * Provides common database operations for all repositories 4 + */ 5 + 6 + import { db } from '../db/client'; 7 + import type { Database } from '../db/types'; 8 + import { Kysely } from 'kysely'; 9 + 10 + export abstract class BaseRepository { 11 + protected db: Kysely<Database>; 12 + 13 + constructor() { 14 + this.db = db; 15 + } 16 + 17 + /** 18 + * Helper: Build arrays organized by column for bulk insert operations 19 + * Returns arrays organized by column for database operations 20 + * 21 + * @example 22 + * const rows = [['val1', 'val2'], ['val3', 'val4']]; 23 + * const [col1, col2] = buildArraysByColumn(['col1', 'col2'], rows); 24 + * // col1 = ['val1', 'val3'], col2 = ['val2', 'val4'] 25 + */ 26 + protected buildArraysByColumn<T extends any[]>( 27 + columns: string[], 28 + rows: T[], 29 + ): any[][] { 30 + return columns.map((_, colIndex) => rows.map((row) => row[colIndex])); 31 + } 32 + 33 + /** 34 + * Helper: Extract results into a Map 35 + * Common pattern for bulk operations that return id mappings 36 + * 37 + * @example 38 + * const results = [{id: 1, username: 'alice'}, {id: 2, username: 'bob'}]; 39 + * const map = buildIdMap(results, 'username', 'id'); 40 + * // map.get('alice') === 1 41 + */ 42 + protected buildIdMap<T extends Record<string, any>>( 43 + results: T[], 44 + keyField: string, 45 + valueField: string = 'id', 46 + ): Map<string, number> { 47 + const map = new Map<string, number>(); 48 + for (const row of results) { 49 + map.set(row[keyField], row[valueField]); 50 + } 51 + return map; 52 + } 53 + }
+227
packages/api/src/repositories/MatchRepository.ts
··· 1 + /** 2 + * Match Repository 3 + * Manages AT Protocol matches for source accounts 4 + */ 5 + 6 + import { BaseRepository } from './BaseRepository'; 7 + import { sql } from 'kysely'; 8 + 9 + export class MatchRepository extends BaseRepository { 10 + /** 11 + * Store a single match (actor found on Bluesky) 12 + */ 13 + async storeMatch( 14 + sourceAccountId: number, 15 + atprotoDid: string, 16 + atprotoHandle: string, 17 + atprotoDisplayName: string | undefined, 18 + atprotoAvatar: string | undefined, 19 + matchScore: number, 20 + postCount?: number, 21 + followerCount?: number, 22 + followStatus?: Record<string, boolean>, 23 + ): Promise<number> { 24 + const result = await this.db 25 + .insertInto('atproto_matches') 26 + .values({ 27 + source_account_id: sourceAccountId, 28 + atproto_did: atprotoDid, 29 + atproto_handle: atprotoHandle, 30 + display_name: atprotoDisplayName || null, 31 + match_score: matchScore, 32 + post_count: postCount || 0, 33 + follower_count: followerCount || 0, 34 + follow_status: followStatus || {}, 35 + }) 36 + .onConflict((oc) => 37 + oc.columns(['source_account_id', 'atproto_did']).doUpdateSet({ 38 + atproto_handle: atprotoHandle, 39 + display_name: atprotoDisplayName || null, 40 + match_score: matchScore, 41 + post_count: postCount || 0, 42 + follower_count: followerCount || 0, 43 + follow_status: sql`COALESCE(atproto_matches.follow_status, '{}'::jsonb) || ${JSON.stringify(followStatus || {})}::jsonb`, 44 + }), 45 + ) 46 + .returning('id') 47 + .executeTakeFirst(); 48 + 49 + return result!.id; 50 + } 51 + 52 + /** 53 + * Bulk store matches 54 + * Returns a map of "sourceAccountId:atprotoDid" -> matchId 55 + */ 56 + async bulkStoreMatches( 57 + matches: Array<{ 58 + sourceAccountId: number; 59 + atprotoDid: string; 60 + atprotoHandle: string; 61 + atprotoDisplayName?: string; 62 + atprotoAvatar?: string; 63 + matchScore: number; 64 + postCount?: number; 65 + followerCount?: number; 66 + followStatus?: Record<string, boolean>; 67 + }>, 68 + ): Promise<Map<string, number>> { 69 + if (matches.length === 0) return new Map(); 70 + 71 + const values = matches.map((m) => ({ 72 + source_account_id: m.sourceAccountId, 73 + atproto_did: m.atprotoDid, 74 + atproto_handle: m.atprotoHandle, 75 + display_name: m.atprotoDisplayName || null, 76 + match_score: m.matchScore, 77 + post_count: m.postCount || 0, 78 + follower_count: m.followerCount || 0, 79 + follow_status: m.followStatus || {}, 80 + })); 81 + 82 + const results = await this.db 83 + .insertInto('atproto_matches') 84 + .values(values) 85 + .onConflict((oc) => 86 + oc.columns(['source_account_id', 'atproto_did']).doUpdateSet({ 87 + atproto_handle: (eb) => eb.ref('excluded.atproto_handle'), 88 + display_name: (eb) => eb.ref('excluded.display_name'), 89 + match_score: (eb) => eb.ref('excluded.match_score'), 90 + post_count: (eb) => eb.ref('excluded.post_count'), 91 + follower_count: (eb) => eb.ref('excluded.follower_count'), 92 + follow_status: sql`COALESCE(atproto_matches.follow_status, '{}'::jsonb) || excluded.follow_status`, 93 + }), 94 + ) 95 + .returning(['id', 'source_account_id', 'atproto_did']) 96 + .execute(); 97 + 98 + const idMap = new Map<string, number>(); 99 + for (const row of results) { 100 + idMap.set(`${row.source_account_id}:${row.atproto_did}`, row.id); 101 + } 102 + 103 + return idMap; 104 + } 105 + 106 + /** 107 + * Update follow status for a match 108 + */ 109 + async updateFollowStatus( 110 + atprotoDid: string, 111 + followLexicon: string, 112 + isFollowing: boolean, 113 + ): Promise<void> { 114 + await this.db 115 + .updateTable('atproto_matches') 116 + .set({ 117 + follow_status: sql`follow_status || jsonb_build_object(${followLexicon}, ${isFollowing})`, 118 + }) 119 + .where('atproto_did', '=', atprotoDid) 120 + .execute(); 121 + } 122 + 123 + /** 124 + * Get upload details with matches 125 + * Used by results endpoints 126 + */ 127 + async getUploadDetails( 128 + uploadId: string, 129 + userDid: string, 130 + page: number = 1, 131 + pageSize: number = 50, 132 + ): Promise<{ 133 + results: any[]; 134 + totalUsers: number; 135 + }> { 136 + // First, verify upload belongs to user 137 + const upload = await this.db 138 + .selectFrom('user_uploads') 139 + .select(['upload_id', 'total_users']) 140 + .where('upload_id', '=', uploadId) 141 + .where('user_did', '=', userDid) 142 + .executeTakeFirst(); 143 + 144 + if (!upload) { 145 + return { results: [], totalUsers: 0 }; 146 + } 147 + 148 + const offset = (page - 1) * pageSize; 149 + 150 + // Get source accounts with their matches 151 + const results = await this.db 152 + .selectFrom('user_source_follows as usf') 153 + .innerJoin('source_accounts as sa', 'usf.source_account_id', 'sa.id') 154 + .innerJoin('user_uploads as uu', 'usf.upload_id', 'uu.upload_id') 155 + .leftJoin('atproto_matches as am', (join) => 156 + join 157 + .onRef('sa.id', '=', 'am.source_account_id') 158 + ) 159 + .leftJoin('user_match_status as ums', (join) => 160 + join 161 + .onRef('am.id', '=', 'ums.atproto_match_id') 162 + .on('ums.user_did', '=', userDid) 163 + ) 164 + .select([ 165 + 'sa.original_username', 166 + 'sa.normalized_username', 167 + 'sa.date_on_source', 168 + 'am.atproto_did', 169 + 'am.atproto_handle', 170 + 'am.display_name', 171 + 'am.match_score', 172 + 'am.post_count', 173 + 'am.follower_count', 174 + 'am.found_at', 175 + 'am.follow_status', 176 + 'ums.dismissed', 177 + sql<number>`CASE WHEN am.found_at > uu.created_at THEN 1 ELSE 0 END`.as('is_new_match'), 178 + ]) 179 + .where('usf.upload_id', '=', uploadId) 180 + .orderBy(sql`CASE WHEN am.atproto_did IS NOT NULL THEN 0 ELSE 1 END`) 181 + .orderBy('is_new_match', 'desc') 182 + .orderBy('am.post_count', 'desc') 183 + .orderBy('am.follower_count', 'desc') 184 + .orderBy('sa.original_username') 185 + .limit(pageSize) 186 + .offset(offset) 187 + .execute(); 188 + 189 + return { 190 + results, 191 + totalUsers: upload.total_users, 192 + }; 193 + } 194 + 195 + /** 196 + * Upsert user match status (viewed, dismissed, etc.) 197 + */ 198 + async upsertUserMatchStatus( 199 + statuses: Array<{ 200 + userDid: string; 201 + atprotoMatchId: number; 202 + sourceAccountId: number; 203 + viewed: boolean; 204 + }>, 205 + ): Promise<void> { 206 + if (statuses.length === 0) return; 207 + 208 + const values = statuses.map((s) => ({ 209 + user_did: s.userDid, 210 + atproto_match_id: s.atprotoMatchId, 211 + source_account_id: s.sourceAccountId, 212 + viewed: s.viewed, 213 + viewed_at: s.viewed ? new Date() : null, 214 + })); 215 + 216 + await this.db 217 + .insertInto('user_match_status') 218 + .values(values) 219 + .onConflict((oc) => 220 + oc.columns(['user_did', 'atproto_match_id']).doUpdateSet({ 221 + viewed: (eb) => eb.ref('excluded.viewed'), 222 + viewed_at: sql`CASE WHEN excluded.viewed THEN NOW() ELSE user_match_status.viewed_at END`, 223 + }), 224 + ) 225 + .execute(); 226 + } 227 + }
+99
packages/api/src/repositories/SourceAccountRepository.ts
··· 1 + /** 2 + * Source Account Repository 3 + * Manages source platform accounts (Instagram, TikTok, Twitter) 4 + */ 5 + 6 + import { BaseRepository } from './BaseRepository'; 7 + import { normalize } from '../utils/string.utils'; 8 + import { sql } from 'kysely'; 9 + 10 + export class SourceAccountRepository extends BaseRepository { 11 + /** 12 + * Get or create a source account 13 + * Uses normalized username for deduplication 14 + */ 15 + async getOrCreate( 16 + sourcePlatform: string, 17 + sourceUsername: string, 18 + ): Promise<number> { 19 + const normalized = normalize(sourceUsername); 20 + 21 + const result = await this.db 22 + .insertInto('source_accounts') 23 + .values({ 24 + source_platform: sourcePlatform, 25 + original_username: sourceUsername, 26 + normalized_username: normalized, 27 + }) 28 + .onConflict((oc) => 29 + oc 30 + .columns(['source_platform', 'normalized_username']) 31 + .doUpdateSet({ 32 + original_username: sourceUsername, 33 + }), 34 + ) 35 + .returning('id') 36 + .executeTakeFirst(); 37 + 38 + return result!.id; 39 + } 40 + 41 + /** 42 + * Bulk create source accounts 43 + * Returns a map of normalized_username -> id 44 + */ 45 + async bulkCreate( 46 + sourcePlatform: string, 47 + usernames: string[], 48 + ): Promise<Map<string, number>> { 49 + if (usernames.length === 0) return new Map(); 50 + 51 + // Prepare values for bulk insert 52 + const values = usernames.map((username) => ({ 53 + source_platform: sourcePlatform, 54 + original_username: username, 55 + normalized_username: normalize(username), 56 + })); 57 + 58 + const results = await this.db 59 + .insertInto('source_accounts') 60 + .values(values) 61 + .onConflict((oc) => 62 + oc 63 + .columns(['source_platform', 'normalized_username']) 64 + .doUpdateSet({ 65 + original_username: (eb) => eb.ref('excluded.original_username'), 66 + }), 67 + ) 68 + .returning(['id', 'normalized_username']) 69 + .execute(); 70 + 71 + return this.buildIdMap(results, 'normalized_username', 'id'); 72 + } 73 + 74 + /** 75 + * Link user to source accounts (for upload tracking) 76 + */ 77 + async linkUserToAccounts( 78 + uploadId: string, 79 + userDid: string, 80 + links: Array<{ sourceAccountId: number; sourceDate?: string }>, 81 + ): Promise<void> { 82 + if (links.length === 0) return; 83 + 84 + const values = links.map((link) => ({ 85 + upload_id: uploadId, 86 + user_did: userDid, 87 + source_account_id: link.sourceAccountId, 88 + date_on_source: link.sourceDate ? new Date(link.sourceDate) : null, 89 + })); 90 + 91 + await this.db 92 + .insertInto('user_source_follows') 93 + .values(values) 94 + .onConflict((oc) => 95 + oc.columns(['upload_id', 'source_account_id']).doNothing(), 96 + ) 97 + .execute(); 98 + } 99 + }
+230
packages/api/src/routes/search.ts
··· 1 + /** 2 + * Search Routes 3 + * Handles batch search for AT Protocol actors 4 + */ 5 + 6 + import { Hono } from 'hono'; 7 + import { z } from 'zod'; 8 + import { authMiddleware } from '../middleware/auth'; 9 + import { searchRateLimit } from '../middleware/rateLimit'; 10 + import { SessionService } from '../services/SessionService'; 11 + import { FollowService } from '../services/FollowService'; 12 + import { normalize } from '../utils/string.utils'; 13 + 14 + const search = new Hono(); 15 + 16 + // Validation schema for batch search request 17 + const batchSearchSchema = z.object({ 18 + usernames: z.array(z.string()).min(1).max(50), 19 + followLexicon: z.string().optional().default('app.bsky.graph.follow'), 20 + }); 21 + 22 + interface RankedActor { 23 + did: string; 24 + handle: string; 25 + displayName?: string; 26 + avatar?: string; 27 + description?: string; 28 + matchScore: number; 29 + postCount?: number; 30 + followerCount?: number; 31 + followStatus?: Record<string, boolean>; 32 + } 33 + 34 + interface EnrichedActor extends RankedActor { 35 + postCount: number; 36 + followerCount: number; 37 + followStatus: Record<string, boolean>; 38 + } 39 + 40 + interface SearchResult { 41 + username: string; 42 + actors: EnrichedActor[]; 43 + error: string | null; 44 + } 45 + 46 + /** 47 + * POST /api/search/batch-search-actors 48 + * Search for multiple usernames on AT Protocol 49 + * 50 + * Rate limit: 10 requests per minute (each can search up to 50 usernames) 51 + */ 52 + search.post( 53 + '/batch-search-actors', 54 + searchRateLimit, 55 + authMiddleware, 56 + async (c) => { 57 + try { 58 + const body = await c.req.json(); 59 + const { usernames, followLexicon } = batchSearchSchema.parse(body); 60 + 61 + const sessionId = c.get('sessionId'); 62 + const did = c.get('did'); 63 + 64 + console.log( 65 + `[Search] Batch search for ${usernames.length} usernames by ${did}`, 66 + ); 67 + 68 + // Get authenticated agent 69 + const { agent } = await SessionService.getAgentForSession(sessionId, c); 70 + 71 + // Search for each username 72 + const searchPromises = usernames.map(async (username) => { 73 + try { 74 + const response = await agent.app.bsky.actor.searchActors({ 75 + q: username, 76 + limit: 20, 77 + }); 78 + 79 + const normalizedUsername = normalize(username); 80 + 81 + // Rank actors by match quality 82 + const rankedActors = response.data.actors 83 + .map((actor): RankedActor => { 84 + const handlePart = actor.handle.split('.')[0]; 85 + const normalizedHandle = normalize(handlePart); 86 + const normalizedFullHandle = normalize(actor.handle); 87 + const normalizedDisplayName = normalize( 88 + actor.displayName || '', 89 + ); 90 + 91 + // Calculate match score 92 + let score = 0; 93 + if (normalizedHandle === normalizedUsername) score = 100; 94 + else if (normalizedFullHandle === normalizedUsername) score = 90; 95 + else if (normalizedDisplayName === normalizedUsername) score = 80; 96 + else if (normalizedHandle.includes(normalizedUsername)) 97 + score = 60; 98 + else if (normalizedFullHandle.includes(normalizedUsername)) 99 + score = 50; 100 + else if (normalizedDisplayName.includes(normalizedUsername)) 101 + score = 40; 102 + else if (normalizedUsername.includes(normalizedHandle)) 103 + score = 30; 104 + 105 + return { 106 + did: actor.did, 107 + handle: actor.handle, 108 + displayName: actor.displayName, 109 + avatar: actor.avatar, 110 + description: actor.description, 111 + matchScore: score, 112 + }; 113 + }) 114 + .filter((actor) => actor.matchScore > 0) 115 + .sort((a, b) => b.matchScore - a.matchScore) 116 + .slice(0, 5); 117 + 118 + return { 119 + username, 120 + actors: rankedActors, 121 + error: null, 122 + }; 123 + } catch (error) { 124 + return { 125 + username, 126 + actors: [], 127 + error: 128 + error instanceof Error ? error.message : 'Search failed', 129 + }; 130 + } 131 + }); 132 + 133 + const results = (await Promise.all(searchPromises)) as SearchResult[]; 134 + 135 + // Collect all DIDs from results 136 + const allDids = results 137 + .flatMap((r) => r.actors.map((a) => a.did)) 138 + .filter((did): did is string => !!did); 139 + 140 + // Enrich with profile data (post count, follower count) 141 + if (allDids.length > 0) { 142 + const profileDataMap = new Map< 143 + string, 144 + { postCount: number; followerCount: number } 145 + >(); 146 + 147 + const PROFILE_BATCH_SIZE = 25; 148 + for (let i = 0; i < allDids.length; i += PROFILE_BATCH_SIZE) { 149 + const batch = allDids.slice(i, i + PROFILE_BATCH_SIZE); 150 + try { 151 + const profilesResponse = 152 + await agent.app.bsky.actor.getProfiles({ 153 + actors: batch, 154 + }); 155 + 156 + profilesResponse.data.profiles.forEach((profile) => { 157 + profileDataMap.set(profile.did, { 158 + postCount: profile.postsCount || 0, 159 + followerCount: profile.followersCount || 0, 160 + }); 161 + }); 162 + } catch (error) { 163 + console.error('Failed to fetch profile batch:', error); 164 + } 165 + } 166 + 167 + // Add profile data to results 168 + results.forEach((result) => { 169 + result.actors = result.actors.map((actor): EnrichedActor => { 170 + const enrichedData = profileDataMap.get(actor.did); 171 + return { 172 + ...actor, 173 + postCount: enrichedData?.postCount || 0, 174 + followerCount: enrichedData?.followerCount || 0, 175 + followStatus: {}, 176 + }; 177 + }); 178 + }); 179 + } 180 + 181 + // Check follow status for all actors 182 + if (allDids.length > 0) { 183 + try { 184 + const followStatus = await FollowService.checkFollowStatus( 185 + agent, 186 + did, 187 + allDids, 188 + followLexicon, 189 + ); 190 + 191 + results.forEach((result) => { 192 + result.actors = result.actors.map((actor): EnrichedActor => ({ 193 + ...actor, 194 + followStatus: { 195 + [followLexicon]: followStatus[actor.did] || false, 196 + }, 197 + })); 198 + }); 199 + } catch (error) { 200 + console.error( 201 + 'Failed to check follow status during search:', 202 + error, 203 + ); 204 + } 205 + } 206 + 207 + console.log( 208 + `[Search] Found ${allDids.length} total actors across ${results.length} searches`, 209 + ); 210 + 211 + return c.json({ 212 + success: true, 213 + data: { results }, 214 + }); 215 + } catch (error) { 216 + if (error instanceof z.ZodError) { 217 + return c.json( 218 + { 219 + success: false, 220 + error: 'Invalid request. Please provide a valid array of usernames (max 50).', 221 + }, 222 + 400, 223 + ); 224 + } 225 + throw error; // Let error middleware handle it 226 + } 227 + }, 228 + ); 229 + 230 + export default search;
+2
packages/api/src/server.ts
··· 6 6 import { logger } from "hono/logger"; 7 7 import { errorHandler } from "./middleware/error"; 8 8 import authRoutes from "./routes/auth"; 9 + import searchRoutes from "./routes/search"; 9 10 import { db } from "./db/client"; 10 11 import { sql } from "kysely"; 11 12 ··· 49 50 50 51 // Mount routes 51 52 app.route("/api/auth", authRoutes); 53 + app.route("/api/search", searchRoutes); 52 54 53 55 // Health check endpoint (Phase 3C - with database check) 54 56 app.get("/api/health", async (c) => {
+94
packages/api/src/services/FollowService.ts
··· 1 + /** 2 + * Follow Service 3 + * Handles all follow-related operations for AT Protocol 4 + */ 5 + 6 + import { Agent } from '@atproto/api'; 7 + 8 + interface FollowStatusResult { 9 + [did: string]: boolean; 10 + } 11 + 12 + export class FollowService { 13 + /** 14 + * Check follow status for multiple DIDs 15 + * Returns a map of DID -> isFollowing 16 + */ 17 + static async checkFollowStatus( 18 + agent: Agent, 19 + userDid: string, 20 + dids: string[], 21 + followLexicon: string = 'app.bsky.graph.follow', 22 + ): Promise<FollowStatusResult> { 23 + const followStatus: FollowStatusResult = {}; 24 + 25 + // Initialize all as not following 26 + dids.forEach((did) => { 27 + followStatus[did] = false; 28 + }); 29 + 30 + if (dids.length === 0) { 31 + return followStatus; 32 + } 33 + 34 + try { 35 + let cursor: string | undefined = undefined; 36 + let hasMore = true; 37 + const didsSet = new Set(dids); 38 + 39 + while (hasMore && didsSet.size > 0) { 40 + const response = await agent.api.com.atproto.repo.listRecords({ 41 + repo: userDid, 42 + collection: followLexicon, 43 + limit: 100, 44 + cursor, 45 + }); 46 + 47 + // Check each record 48 + for (const record of response.data.records) { 49 + const followRecord = record.value as any; 50 + if (followRecord?.subject && didsSet.has(followRecord.subject)) { 51 + followStatus[followRecord.subject] = true; 52 + didsSet.delete(followRecord.subject); // Found it, no need to keep checking 53 + } 54 + } 55 + 56 + cursor = response.data.cursor; 57 + hasMore = !!cursor; 58 + 59 + // If we've found all DIDs, break early 60 + if (didsSet.size === 0) { 61 + break; 62 + } 63 + } 64 + } catch (error) { 65 + console.error('Error checking follow status:', error); 66 + // Return all as false on error (fail-safe) 67 + } 68 + 69 + return followStatus; 70 + } 71 + 72 + /** 73 + * Get list of already followed DIDs from a set 74 + */ 75 + static async getAlreadyFollowing( 76 + agent: Agent, 77 + userDid: string, 78 + dids: string[], 79 + followLexicon: string = 'app.bsky.graph.follow', 80 + ): Promise<Set<string>> { 81 + const followStatus = await this.checkFollowStatus( 82 + agent, 83 + userDid, 84 + dids, 85 + followLexicon, 86 + ); 87 + 88 + return new Set( 89 + Object.entries(followStatus) 90 + .filter(([_, isFollowing]) => isFollowing) 91 + .map(([did]) => did), 92 + ); 93 + } 94 + }
+134
packages/api/src/services/SessionService.ts
··· 1 + /** 2 + * Session Service for Hono 3 + * Handles OAuth session management and agent creation 4 + */ 5 + 6 + import { Agent } from '@atproto/api'; 7 + import type { NodeOAuthClient } from '@atproto/oauth-client-node'; 8 + import { Context } from 'hono'; 9 + import { createOAuthClient } from '../infrastructure/oauth/OAuthClientFactory'; 10 + import { userSessionStore, sessionStore } from '../infrastructure/oauth'; 11 + import { AuthenticationError, ERROR_MESSAGES } from '../errors'; 12 + 13 + // Simple in-memory cache for OAuth clients (5 min expiry) 14 + const clientCache = new Map< 15 + string, 16 + { client: NodeOAuthClient; expiresAt: number } 17 + >(); 18 + 19 + export class SessionService { 20 + /** 21 + * Get an authenticated agent for a session 22 + * Handles session validation, OAuth client creation, and agent restoration 23 + */ 24 + static async getAgentForSession( 25 + sessionId: string, 26 + c: Context, 27 + ): Promise<{ 28 + agent: Agent; 29 + did: string; 30 + client: NodeOAuthClient; 31 + }> { 32 + console.log('[SessionService] Getting agent for session:', sessionId); 33 + 34 + // Get user session 35 + const userSession = await userSessionStore.get(sessionId); 36 + if (!userSession) { 37 + throw new AuthenticationError(ERROR_MESSAGES.INVALID_SESSION); 38 + } 39 + 40 + const did = userSession.did; 41 + console.log('[SessionService] Found user session for DID:', did); 42 + 43 + // Cache the OAuth client per session for 5 minutes 44 + const host = c.req.header('host') || 'default'; 45 + const cacheKey = `oauth-client-${sessionId}-${host}`; 46 + const cached = clientCache.get(cacheKey); 47 + 48 + let client: NodeOAuthClient; 49 + 50 + if (cached && cached.expiresAt > Date.now()) { 51 + client = cached.client; 52 + console.log('[SessionService] Using cached OAuth client'); 53 + } else { 54 + client = await createOAuthClient(c); 55 + clientCache.set(cacheKey, { 56 + client, 57 + expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes 58 + }); 59 + console.log('[SessionService] Created and cached OAuth client'); 60 + } 61 + 62 + try { 63 + const oauthSession = await client.restore(did); 64 + console.log('[SessionService] Restored OAuth session for DID:', did); 65 + 66 + // Log token rotation for monitoring 67 + const sessionData = await sessionStore.get(did); 68 + if (sessionData) { 69 + console.log('[SessionService] OAuth session restored/refreshed'); 70 + } 71 + 72 + const agent = new Agent(oauthSession); 73 + return { agent, did, client }; 74 + } catch (error) { 75 + console.error( 76 + '[SessionService] Failed to restore session:', 77 + error instanceof Error ? error.message : String(error), 78 + ); 79 + // Clear the cached client if restore fails 80 + clientCache.delete(cacheKey); 81 + throw new AuthenticationError( 82 + 'Failed to restore OAuth session', 83 + error instanceof Error ? error.message : 'Session restoration failed', 84 + ); 85 + } 86 + } 87 + 88 + /** 89 + * Delete a session (logout) 90 + */ 91 + static async deleteSession(sessionId: string, c: Context): Promise<void> { 92 + console.log('[SessionService] Deleting session:', sessionId); 93 + 94 + const userSession = await userSessionStore.get(sessionId); 95 + if (!userSession) { 96 + console.log('[SessionService] Session not found:', sessionId); 97 + return; 98 + } 99 + 100 + const did = userSession.did; 101 + 102 + try { 103 + const client = await createOAuthClient(c); 104 + await client.revoke(did); 105 + console.log('[SessionService] Revoked OAuth session for DID:', did); 106 + } catch (error) { 107 + console.log('[SessionService] Could not revoke OAuth session:', error); 108 + } 109 + 110 + await userSessionStore.del(sessionId); 111 + 112 + // Clear cached OAuth client 113 + const host = c.req.header('host') || 'default'; 114 + clientCache.delete(`oauth-client-${sessionId}-${host}`); 115 + 116 + console.log('[SessionService] Deleted user session:', sessionId); 117 + } 118 + 119 + /** 120 + * Verify session exists 121 + */ 122 + static async verifySession(sessionId: string): Promise<boolean> { 123 + const userSession = await userSessionStore.get(sessionId); 124 + return userSession !== undefined; 125 + } 126 + 127 + /** 128 + * Get DID for session 129 + */ 130 + static async getDIDForSession(sessionId: string): Promise<string | null> { 131 + const userSession = await userSessionStore.get(sessionId); 132 + return userSession?.did || null; 133 + } 134 + }