Sifa professional network API (Fastify, AT Protocol, Jetstream) sifa.id/

feat(search): resolve AT Protocol handles in search (#85)

* feat(search): add AT Protocol handle resolution fallback

When the local DB returns no results for a search query, try to resolve
the query as an AT Protocol handle via the Bluesky public API. Results
from the DB get `claimed: true`; network-resolved profiles that aren't
already in DB results are appended with `claimed: false`. Short queries
without dots also try {query}.bsky.social. Resolution failures are
silently swallowed so DB results always return.

* style: fix Prettier formatting

authored by

Guido X Jansen and committed by
GitHub
23a56ca8 eedd6290

+211 -17
+40 -16
src/routes/search.ts
··· 1 1 import type { FastifyInstance } from 'fastify'; 2 2 import type { Database } from '../db/index.js'; 3 3 import { sql } from 'drizzle-orm'; 4 + import { resolveHandleFromNetwork } from '../services/handle-resolver.js'; 5 + import { logger } from '../logger.js'; 4 6 5 7 export function registerSearchRoutes(app: FastifyInstance, db: Database) { 6 8 app.get('/api/search/profiles', async (request, reply) => { ··· 32 34 LIMIT ${limitNum} OFFSET ${offsetNum} 33 35 `); 34 36 35 - return { 36 - profiles: results.rows.map((row) => { 37 - const r = row as Record<string, unknown>; 38 - const profile: Record<string, unknown> = { 39 - did: r.did, 40 - handle: r.handle, 41 - headline: r.headline, 42 - about: r.about, 43 - }; 44 - if (r.displayName != null) profile.displayName = r.displayName; 45 - if (r.avatarUrl != null) profile.avatar = r.avatarUrl; 46 - if (r.currentRole != null) profile.currentRole = r.currentRole; 47 - if (r.currentCompany != null) profile.currentCompany = r.currentCompany; 48 - return profile; 49 - }), 50 - }; 37 + const dbProfiles = results.rows.map((row) => { 38 + const r = row as Record<string, unknown>; 39 + const profile: Record<string, unknown> = { 40 + did: r.did, 41 + handle: r.handle, 42 + headline: r.headline, 43 + about: r.about, 44 + claimed: true, 45 + }; 46 + if (r.displayName != null) profile.displayName = r.displayName; 47 + if (r.avatarUrl != null) profile.avatar = r.avatarUrl; 48 + if (r.currentRole != null) profile.currentRole = r.currentRole; 49 + if (r.currentCompany != null) profile.currentCompany = r.currentCompany; 50 + return profile; 51 + }); 52 + 53 + // Try AT Protocol handle resolution as fallback 54 + try { 55 + const resolved = await resolveHandleFromNetwork(q.trim()); 56 + if (resolved) { 57 + const alreadyInResults = dbProfiles.some((p) => p.did === resolved.did); 58 + if (!alreadyInResults) { 59 + const networkProfile: Record<string, unknown> = { 60 + did: resolved.did, 61 + handle: resolved.handle, 62 + about: resolved.about, 63 + claimed: false, 64 + }; 65 + if (resolved.displayName != null) networkProfile.displayName = resolved.displayName; 66 + if (resolved.avatar != null) networkProfile.avatar = resolved.avatar; 67 + dbProfiles.push(networkProfile); 68 + } 69 + } 70 + } catch (err) { 71 + logger.debug({ err, query: q }, 'Handle resolution fallback failed'); 72 + } 73 + 74 + return { profiles: dbProfiles }; 51 75 }); 52 76 }
+50
src/services/handle-resolver.ts
··· 1 + import { isValidHandle } from '@atproto/syntax'; 2 + import { Agent } from '@atproto/api'; 3 + import { logger } from '../logger.js'; 4 + 5 + export interface ResolvedProfile { 6 + did: string; 7 + handle: string; 8 + displayName: string | undefined; 9 + avatar: string | undefined; 10 + about: string | undefined; 11 + } 12 + 13 + const SIMPLE_HANDLE_RE = /^[a-zA-Z0-9-]+$/; 14 + const publicAgent = new Agent('https://public.api.bsky.app'); 15 + 16 + export async function resolveHandleFromNetwork(query: string): Promise<ResolvedProfile | null> { 17 + const candidates: string[] = []; 18 + 19 + if (isValidHandle(query)) { 20 + candidates.push(query); 21 + } 22 + 23 + // If no dots and looks like a simple username, also try .bsky.social 24 + if (SIMPLE_HANDLE_RE.test(query) && !query.includes('.')) { 25 + const bskySocial = `${query}.bsky.social`; 26 + if (isValidHandle(bskySocial) && !candidates.includes(bskySocial)) { 27 + candidates.push(bskySocial); 28 + } 29 + } 30 + 31 + for (const handle of candidates) { 32 + try { 33 + const response = await publicAgent.getProfile( 34 + { actor: handle }, 35 + { signal: AbortSignal.timeout(3000) }, 36 + ); 37 + return { 38 + did: response.data.did, 39 + handle: response.data.handle, 40 + displayName: response.data.displayName || undefined, 41 + avatar: response.data.avatar || undefined, 42 + about: response.data.description || undefined, 43 + }; 44 + } catch (err) { 45 + logger.debug({ handle, err }, 'Handle resolution failed'); 46 + } 47 + } 48 + 49 + return null; 50 + }
+121 -1
tests/routes/search.test.ts
··· 1 - import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 1 + import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest'; 2 2 import { buildServer } from '../../src/server.js'; 3 3 import { createDb } from '../../src/db/index.js'; 4 4 import { profiles, positions } from '../../src/db/schema/index.js'; ··· 7 7 import { tmpdir } from 'node:os'; 8 8 import { join } from 'node:path'; 9 9 import type { FastifyInstance } from 'fastify'; 10 + 11 + vi.mock('../../src/services/handle-resolver.js', () => ({ 12 + resolveHandleFromNetwork: vi.fn().mockResolvedValue(null), 13 + })); 10 14 11 15 describe('Search API', () => { 12 16 let app: FastifyInstance; ··· 171 175 for (const profile of body.profiles) { 172 176 expect(profile.rank).toBeUndefined(); 173 177 } 178 + }); 179 + 180 + it('DB results include claimed: true', async () => { 181 + const res = await app.inject({ method: 'GET', url: '/api/search/profiles?q=TypeScript' }); 182 + expect(res.statusCode).toBe(200); 183 + const body = res.json(); 184 + expect(body.profiles.length).toBeGreaterThanOrEqual(1); 185 + for (const profile of body.profiles) { 186 + expect(profile.claimed).toBe(true); 187 + } 188 + }); 189 + 190 + describe('AT Protocol handle resolution fallback', () => { 191 + let handleResolverMock: typeof import('../../src/services/handle-resolver.js'); 192 + 193 + beforeEach(async () => { 194 + handleResolverMock = await import('../../src/services/handle-resolver.js'); 195 + vi.mocked(handleResolverMock.resolveHandleFromNetwork).mockReset(); 196 + vi.mocked(handleResolverMock.resolveHandleFromNetwork).mockResolvedValue(null); 197 + }); 198 + 199 + it('resolves AT Protocol handle when DB has no results and returns claimed: false', async () => { 200 + vi.mocked(handleResolverMock.resolveHandleFromNetwork).mockResolvedValue({ 201 + did: 'did:plc:network-resolved', 202 + handle: 'networkuser.bsky.social', 203 + displayName: 'Network User', 204 + avatar: 'https://cdn.bsky.app/img/avatar/network.jpg', 205 + about: 'Found via network', 206 + }); 207 + 208 + const res = await app.inject({ 209 + method: 'GET', 210 + url: '/api/search/profiles?q=networkuser.bsky.social', 211 + }); 212 + expect(res.statusCode).toBe(200); 213 + const body = res.json(); 214 + expect(body.profiles.length).toBe(1); 215 + const profile = body.profiles[0]; 216 + expect(profile.did).toBe('did:plc:network-resolved'); 217 + expect(profile.handle).toBe('networkuser.bsky.social'); 218 + expect(profile.displayName).toBe('Network User'); 219 + expect(profile.avatar).toBe('https://cdn.bsky.app/img/avatar/network.jpg'); 220 + expect(profile.about).toBe('Found via network'); 221 + expect(profile.claimed).toBe(false); 222 + }); 223 + 224 + it('returns empty when handle resolution also fails', async () => { 225 + vi.mocked(handleResolverMock.resolveHandleFromNetwork).mockResolvedValue(null); 226 + 227 + const res = await app.inject({ 228 + method: 'GET', 229 + url: '/api/search/profiles?q=doesnotexist.bsky.social', 230 + }); 231 + expect(res.statusCode).toBe(200); 232 + expect(res.json().profiles).toHaveLength(0); 233 + }); 234 + 235 + it('short queries without dots get .bsky.social appended via resolver', async () => { 236 + vi.mocked(handleResolverMock.resolveHandleFromNetwork).mockResolvedValue({ 237 + did: 'did:plc:short-handle', 238 + handle: 'shortname.bsky.social', 239 + displayName: 'Short Name', 240 + avatar: undefined, 241 + about: undefined, 242 + }); 243 + 244 + const res = await app.inject({ 245 + method: 'GET', 246 + url: '/api/search/profiles?q=shortname', 247 + }); 248 + expect(res.statusCode).toBe(200); 249 + const body = res.json(); 250 + // The resolver was called with the query; it handles .bsky.social appending internally 251 + expect(vi.mocked(handleResolverMock.resolveHandleFromNetwork)).toHaveBeenCalledWith( 252 + 'shortname', 253 + ); 254 + const profile = body.profiles.find((p: { did: string }) => p.did === 'did:plc:short-handle'); 255 + expect(profile).toBeDefined(); 256 + expect(profile.claimed).toBe(false); 257 + }); 258 + 259 + it('does not duplicate when handle resolves to DID already in DB', async () => { 260 + vi.mocked(handleResolverMock.resolveHandleFromNetwork).mockResolvedValue({ 261 + did: 'did:plc:search-test', 262 + handle: 'searchtest.bsky.social', 263 + displayName: 'Alice Wonderland', 264 + avatar: 'https://cdn.bsky.app/img/avatar/did:plc:search-test/test.jpg', 265 + about: 'Building distributed systems', 266 + }); 267 + 268 + const res = await app.inject({ 269 + method: 'GET', 270 + url: '/api/search/profiles?q=TypeScript', 271 + }); 272 + expect(res.statusCode).toBe(200); 273 + const body = res.json(); 274 + const aliceResults = body.profiles.filter( 275 + (p: { did: string }) => p.did === 'did:plc:search-test', 276 + ); 277 + expect(aliceResults).toHaveLength(1); 278 + expect(aliceResults[0].claimed).toBe(true); 279 + }); 280 + 281 + it('silently returns DB results when resolution throws', async () => { 282 + vi.mocked(handleResolverMock.resolveHandleFromNetwork).mockRejectedValue( 283 + new Error('Network error'), 284 + ); 285 + 286 + const res = await app.inject({ 287 + method: 'GET', 288 + url: '/api/search/profiles?q=TypeScript', 289 + }); 290 + expect(res.statusCode).toBe(200); 291 + const body = res.json(); 292 + expect(body.profiles.length).toBeGreaterThanOrEqual(1); 293 + }); 174 294 }); 175 295 });