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

feat(api): integrate Keytrace claims into activity feed and external accounts (#130) (#141)

Register Keytrace (dev.keytrace.claim) in the ATproto app registry so
claims appear in the cross-app activity feed. Extend the external
accounts endpoint to fetch Keytrace claims from the user's PDS, merge
them with Sifa-declared accounts using URL normalization and
platform/username matching, and surface cryptographic verification
status. Claims are cached in Valkey with a 2-hour TTL, invalidated
on profile sync.

authored by

Guido X Jansen and committed by
GitHub
7fa86da2 f98b2367

+695 -11
+9
src/lib/atproto-app-registry.ts
··· 116 116 urlPattern: 'https://roomy.space', 117 117 color: 'purple', 118 118 }, 119 + { 120 + id: 'keytrace', 121 + name: 'Keytrace', 122 + category: 'Links', 123 + collectionPrefixes: ['dev.keytrace'], 124 + scanCollections: ['dev.keytrace.claim'], 125 + urlPattern: 'https://keytrace.dev/{handle}', 126 + color: 'indigo', 127 + }, 119 128 ]; 120 129 121 130 export const EXCLUDED_COLLECTIONS: string[] = [
+54
src/lib/url-utils.ts
··· 1 + export function normalizeUrl(url: string): string { 2 + let normalized = url.toLowerCase(); 3 + normalized = normalized.replace(/^https?:\/\//, ''); 4 + normalized = normalized.replace(/^www\./, ''); 5 + normalized = normalized.replace(/\/+$/, ''); 6 + return normalized; 7 + } 8 + 9 + const PLATFORM_EXTRACTORS: Record<string, (url: URL) => string | null> = { 10 + github: (url) => { 11 + const parts = url.pathname.split('/').filter(Boolean); 12 + return parts[0] ?? null; 13 + }, 14 + linkedin: (url) => { 15 + const parts = url.pathname.split('/').filter(Boolean); 16 + if (parts[0] === 'in' && parts[1]) return parts[1]; 17 + return null; 18 + }, 19 + twitter: (url) => { 20 + const parts = url.pathname.split('/').filter(Boolean); 21 + return parts[0] ?? null; 22 + }, 23 + instagram: (url) => { 24 + const parts = url.pathname.split('/').filter(Boolean); 25 + return parts[0] ?? null; 26 + }, 27 + youtube: (url) => { 28 + const parts = url.pathname.split('/').filter(Boolean); 29 + if (parts[0] === 'channel' && parts[1]) return parts[1]; 30 + if (parts[0]?.startsWith('@')) return parts[0]; 31 + return null; 32 + }, 33 + fediverse: (url) => { 34 + const parts = url.pathname.split('/').filter(Boolean); 35 + if (parts[0]?.startsWith('@')) return `${parts[0]}@${url.hostname}`; 36 + return null; 37 + }, 38 + }; 39 + 40 + export function extractPlatformUsername(platform: string, url: string): string | null { 41 + const normalizedPlatform = platform.toLowerCase(); 42 + const extractor = PLATFORM_EXTRACTORS[normalizedPlatform]; 43 + if (!extractor) return null; 44 + 45 + try { 46 + let parsableUrl = url; 47 + if (!/^https?:\/\//i.test(parsableUrl)) { 48 + parsableUrl = `https://${parsableUrl}`; 49 + } 50 + return extractor(new URL(parsableUrl)); 51 + } catch { 52 + return null; 53 + } 54 + }
+22 -10
src/routes/external-accounts.ts
··· 16 16 import { discoverFeedUrl, fetchFeedItems } from '../services/feed-discovery.js'; 17 17 import { checkAndStoreVerification, isVerifiablePlatform } from '../services/verification.js'; 18 18 import { indexRecord, deleteRecord } from '../services/record-indexer.js'; 19 + import { fetchKeytraceClaims, mergeExternalAccounts } from '../services/keytrace.js'; 20 + import { resolvePdsHost } from '../lib/pds-provider.js'; 19 21 20 22 const FEED_CACHE_TTL = 1800; // 30 minutes 21 23 ··· 277 279 return reply.status(404).send({ error: 'NotFound', message: 'Profile not found' }); 278 280 } 279 281 280 - const accounts = await db 281 - .select() 282 - .from(externalAccounts) 283 - .where(eq(externalAccounts.did, profile.did)); 282 + // Fetch Sifa accounts + verifications and Keytrace claims in parallel 283 + const pdsHost = profile.pdsHost ?? (await resolvePdsHost(profile.did)); 284 284 285 - const verifications = await db 286 - .select() 287 - .from(externalAccountVerifications) 288 - .where(eq(externalAccountVerifications.did, profile.did)); 285 + const [accounts, verifications, keytraceClaims] = await Promise.all([ 286 + db.select().from(externalAccounts).where(eq(externalAccounts.did, profile.did)), 287 + db 288 + .select() 289 + .from(externalAccountVerifications) 290 + .where(eq(externalAccountVerifications.did, profile.did)), 291 + pdsHost ? fetchKeytraceClaims(profile.did, pdsHost, valkey) : Promise.resolve([]), 292 + ]); 289 293 290 294 const verificationMap = new Map( 291 295 verifications.map((v) => [v.url, { verified: v.verified, verifiedVia: v.verifiedVia }]), 292 296 ); 293 297 294 - const result = accounts.map((acc) => { 298 + const sifaAccounts = accounts.map((acc) => { 295 299 const verification = verificationMap.get(acc.url); 296 300 const verifiable = isVerifiablePlatform(acc.platform); 297 301 ··· 301 305 url: acc.url, 302 306 label: acc.label, 303 307 feedUrl: acc.feedUrl, 304 - primary: acc.isPrimary, 308 + isPrimary: acc.isPrimary, 305 309 verifiable, 306 310 verified: verification?.verified ?? false, 307 311 verifiedVia: verification?.verifiedVia ?? null, 308 312 }; 309 313 }); 314 + 315 + const merged = mergeExternalAccounts(sifaAccounts, keytraceClaims); 316 + 317 + // Map isPrimary to primary for backwards compatibility with frontend 318 + const result = merged.map(({ isPrimary, ...rest }) => ({ 319 + ...rest, 320 + primary: isPrimary, 321 + })); 310 322 311 323 return reply.send({ accounts: result }); 312 324 },
+6
src/routes/profile-write.ts
··· 3 3 import { Agent } from '@atproto/api'; 4 4 import { z } from 'zod'; 5 5 import type { Database } from '../db/index.js'; 6 + import type { ValkeyClient } from '../cache/index.js'; 6 7 import { eq } from 'drizzle-orm'; 7 8 import { 8 9 profiles as profilesTable, ··· 55 56 import { scanUserApps } from '../services/pds-scanner.js'; 56 57 import { upsertScanResults } from '../services/app-stats.js'; 57 58 import { resolvePdsHost } from '../lib/pds-provider.js'; 59 + import { invalidateKeytraceCache } from '../services/keytrace.js'; 58 60 59 61 const overrideSchema = z.object({ 60 62 headline: z.string().max(300).nullish(), ··· 67 69 db: Database, 68 70 oauthClient: NodeOAuthClient | null, 69 71 storage: StorageService, 72 + valkey: ValkeyClient | null, 70 73 ) { 71 74 const requireAuth = createAuthMiddleware(oauthClient, db); 72 75 ··· 969 972 app.log.warn({ err, did }, 'Cross-app activity scan failed during profile sync'); 970 973 // Non-fatal — profile sync still succeeds even if activity scan fails 971 974 } 975 + 976 + // Invalidate Keytrace cache so next profile view fetches fresh claims 977 + await invalidateKeytraceCache(did, valkey); 972 978 973 979 return reply.send({ synced }); 974 980 });
+1 -1
src/server.ts
··· 152 152 registerOAuthMetadata(app, config); 153 153 registerOAuthRoutes(app, db, oauthClient); 154 154 registerProfileRoutes(app, db, valkey); 155 - registerProfileWriteRoutes(app, db, oauthClient, storage); 155 + registerProfileWriteRoutes(app, db, oauthClient, storage, valkey); 156 156 registerImportRoutes(app, db, oauthClient); 157 157 registerFollowRoutes(app, db, oauthClient); 158 158 registerSearchRoutes(app, db);
+221
src/services/keytrace.ts
··· 1 + import { Agent } from '@atproto/api'; 2 + import { z } from 'zod'; 3 + import type { ValkeyClient } from '../cache/index.js'; 4 + import { normalizeUrl, extractPlatformUsername } from '../lib/url-utils.js'; 5 + import { logger as rootLogger } from '../logger.js'; 6 + 7 + const logger = rootLogger.child({ module: 'keytrace' }); 8 + 9 + const KEYTRACE_CACHE_TTL = 7200; // 2 hours 10 + const KEYTRACE_COLLECTION = 'dev.keytrace.claim'; 11 + const PDS_TIMEOUT_MS = 3000; 12 + 13 + const keytraceClaimSchema = z.object({ 14 + url: z.string().min(1), 15 + platform: z.string().min(1), 16 + createdAt: z.string().optional(), 17 + }); 18 + 19 + export interface KeytraceClaim { 20 + rkey: string; 21 + platform: string; 22 + url: string; 23 + claimedAt: string; 24 + } 25 + 26 + export interface SifaExternalAccount { 27 + rkey: string; 28 + platform: string; 29 + url: string; 30 + label: string | null; 31 + feedUrl: string | null; 32 + isPrimary: boolean; 33 + verified: boolean; 34 + verifiedVia: string | null; 35 + verifiable: boolean; 36 + } 37 + 38 + export interface MergedExternalAccount { 39 + rkey: string; 40 + platform: string; 41 + url: string; 42 + label: string | null; 43 + feedUrl: string | null; 44 + isPrimary: boolean; 45 + verifiable: boolean; 46 + verified: boolean; 47 + verifiedVia: string | null; 48 + source: 'sifa' | 'keytrace'; 49 + keytraceVerified: boolean; 50 + keytraceClaim?: { 51 + rkey: string; 52 + claimedAt: string; 53 + }; 54 + } 55 + 56 + function cacheKey(did: string): string { 57 + return `keytrace:claims:${did}`; 58 + } 59 + 60 + export async function invalidateKeytraceCache( 61 + did: string, 62 + valkey: ValkeyClient | null, 63 + ): Promise<void> { 64 + if (!valkey) return; 65 + try { 66 + await valkey.del(cacheKey(did)); 67 + } catch (err) { 68 + logger.warn({ err, did }, 'Failed to invalidate Keytrace cache'); 69 + } 70 + } 71 + 72 + export async function fetchKeytraceClaims( 73 + did: string, 74 + pdsHost: string, 75 + valkey: ValkeyClient | null, 76 + ): Promise<KeytraceClaim[]> { 77 + // Check cache first 78 + if (valkey) { 79 + try { 80 + const cached = await valkey.get(cacheKey(did)); 81 + if (cached) { 82 + return JSON.parse(cached) as KeytraceClaim[]; 83 + } 84 + } catch (err) { 85 + logger.warn({ err, did }, 'Failed to read Keytrace cache'); 86 + } 87 + } 88 + 89 + // Fetch from PDS 90 + try { 91 + const agent = new Agent(`https://${pdsHost}`); 92 + const res = await agent.com.atproto.repo.listRecords( 93 + { 94 + repo: did, 95 + collection: KEYTRACE_COLLECTION, 96 + limit: 100, 97 + }, 98 + { signal: AbortSignal.timeout(PDS_TIMEOUT_MS) }, 99 + ); 100 + 101 + const claims: KeytraceClaim[] = []; 102 + for (const record of res.data.records) { 103 + const parsed = keytraceClaimSchema.safeParse(record.value); 104 + if (!parsed.success) continue; 105 + 106 + const rkey = record.uri.split('/').pop() ?? ''; 107 + claims.push({ 108 + rkey, 109 + platform: parsed.data.platform, 110 + url: parsed.data.url, 111 + claimedAt: parsed.data.createdAt ?? new Date().toISOString(), 112 + }); 113 + } 114 + 115 + // Cache the result 116 + if (valkey) { 117 + try { 118 + await valkey.setex(cacheKey(did), KEYTRACE_CACHE_TTL, JSON.stringify(claims)); 119 + } catch (err) { 120 + logger.warn({ err, did }, 'Failed to cache Keytrace claims'); 121 + } 122 + } 123 + 124 + return claims; 125 + } catch (err) { 126 + logger.warn({ err, did }, 'Failed to fetch Keytrace claims from PDS'); 127 + return []; 128 + } 129 + } 130 + 131 + export function matchClaimsToAccounts( 132 + claims: KeytraceClaim[], 133 + accounts: SifaExternalAccount[], 134 + ): { matched: Map<string, KeytraceClaim>; unmatched: KeytraceClaim[] } { 135 + const matched = new Map<string, KeytraceClaim>(); 136 + const unmatched: KeytraceClaim[] = []; 137 + 138 + for (const claim of claims) { 139 + let found = false; 140 + const claimNormalized = normalizeUrl(claim.url); 141 + 142 + // Primary: normalized URL match 143 + for (const account of accounts) { 144 + if (matched.has(account.rkey)) continue; 145 + if (normalizeUrl(account.url) === claimNormalized) { 146 + matched.set(account.rkey, claim); 147 + found = true; 148 + break; 149 + } 150 + } 151 + 152 + // Fallback: platform + username match 153 + if (!found) { 154 + const claimUsername = extractPlatformUsername(claim.platform, claim.url); 155 + if (claimUsername) { 156 + for (const account of accounts) { 157 + if (matched.has(account.rkey)) continue; 158 + if (account.platform.toLowerCase() !== claim.platform.toLowerCase()) continue; 159 + const accountUsername = extractPlatformUsername(account.platform, account.url); 160 + if (accountUsername && accountUsername.toLowerCase() === claimUsername.toLowerCase()) { 161 + matched.set(account.rkey, claim); 162 + found = true; 163 + break; 164 + } 165 + } 166 + } 167 + } 168 + 169 + if (!found) { 170 + unmatched.push(claim); 171 + } 172 + } 173 + 174 + return { matched, unmatched }; 175 + } 176 + 177 + export function mergeExternalAccounts( 178 + accounts: SifaExternalAccount[], 179 + claims: KeytraceClaim[], 180 + ): MergedExternalAccount[] { 181 + const { matched, unmatched } = matchClaimsToAccounts(claims, accounts); 182 + 183 + const merged: MergedExternalAccount[] = accounts.map((account) => { 184 + const claim = matched.get(account.rkey); 185 + return { 186 + ...account, 187 + source: 'sifa' as const, 188 + keytraceVerified: !!claim, 189 + ...(claim 190 + ? { 191 + keytraceClaim: { 192 + rkey: claim.rkey, 193 + claimedAt: claim.claimedAt, 194 + }, 195 + } 196 + : {}), 197 + }; 198 + }); 199 + 200 + for (const claim of unmatched) { 201 + merged.push({ 202 + rkey: claim.rkey, 203 + platform: claim.platform, 204 + url: claim.url, 205 + label: null, 206 + feedUrl: null, 207 + isPrimary: false, 208 + verifiable: false, 209 + verified: false, 210 + verifiedVia: null, 211 + source: 'keytrace', 212 + keytraceVerified: true, 213 + keytraceClaim: { 214 + rkey: claim.rkey, 215 + claimedAt: claim.claimedAt, 216 + }, 217 + }); 218 + } 219 + 220 + return merged; 221 + }
+7
tests/lib/atproto-app-registry.test.ts
··· 119 119 expect(result?.id).toBe('roomy'); 120 120 }); 121 121 122 + it('maps dev.keytrace.claim to keytrace via scanCollections', () => { 123 + const result = getAppForCollection('dev.keytrace.claim'); 124 + expect(result).toBeDefined(); 125 + expect(result?.id).toBe('keytrace'); 126 + expect(result?.matchedPrefix).toBe('dev.keytrace'); 127 + }); 128 + 122 129 it('returns undefined for unknown collections', () => { 123 130 const result = getAppForCollection('com.unknown.something'); 124 131 expect(result).toBeUndefined();
+108
tests/lib/url-utils.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { normalizeUrl, extractPlatformUsername } from '../../src/lib/url-utils.js'; 3 + 4 + describe('url-utils', () => { 5 + describe('normalizeUrl', () => { 6 + it('strips https protocol', () => { 7 + expect(normalizeUrl('https://github.com/gxjansen')).toBe('github.com/gxjansen'); 8 + }); 9 + 10 + it('strips http protocol', () => { 11 + expect(normalizeUrl('http://github.com/gxjansen')).toBe('github.com/gxjansen'); 12 + }); 13 + 14 + it('strips trailing slash', () => { 15 + expect(normalizeUrl('https://github.com/gxjansen/')).toBe('github.com/gxjansen'); 16 + }); 17 + 18 + it('strips www prefix', () => { 19 + expect(normalizeUrl('https://www.linkedin.com/in/gxjansen')).toBe('linkedin.com/in/gxjansen'); 20 + }); 21 + 22 + it('lowercases the URL', () => { 23 + expect(normalizeUrl('https://GitHub.COM/GxJansen')).toBe('github.com/gxjansen'); 24 + }); 25 + 26 + it('handles multiple normalizations together', () => { 27 + expect(normalizeUrl('HTTP://WWW.GitHub.Com/GxJansen/')).toBe('github.com/gxjansen'); 28 + }); 29 + 30 + it('handles URLs without protocol', () => { 31 + expect(normalizeUrl('github.com/gxjansen')).toBe('github.com/gxjansen'); 32 + }); 33 + 34 + it('handles empty string', () => { 35 + expect(normalizeUrl('')).toBe(''); 36 + }); 37 + 38 + it('strips multiple trailing slashes', () => { 39 + expect(normalizeUrl('https://example.com///')).toBe('example.com'); 40 + }); 41 + }); 42 + 43 + describe('extractPlatformUsername', () => { 44 + it('extracts GitHub username', () => { 45 + expect(extractPlatformUsername('github', 'https://github.com/gxjansen')).toBe('gxjansen'); 46 + }); 47 + 48 + it('extracts GitHub username with trailing slash', () => { 49 + expect(extractPlatformUsername('github', 'https://github.com/gxjansen/')).toBe('gxjansen'); 50 + }); 51 + 52 + it('extracts LinkedIn username', () => { 53 + expect(extractPlatformUsername('linkedin', 'https://www.linkedin.com/in/gxjansen')).toBe( 54 + 'gxjansen', 55 + ); 56 + }); 57 + 58 + it('extracts LinkedIn username with trailing slash', () => { 59 + expect(extractPlatformUsername('linkedin', 'https://linkedin.com/in/gxjansen/')).toBe( 60 + 'gxjansen', 61 + ); 62 + }); 63 + 64 + it('extracts Twitter/X username from twitter.com', () => { 65 + expect(extractPlatformUsername('twitter', 'https://twitter.com/guido')).toBe('guido'); 66 + }); 67 + 68 + it('extracts Twitter/X username from x.com', () => { 69 + expect(extractPlatformUsername('twitter', 'https://x.com/guido')).toBe('guido'); 70 + }); 71 + 72 + it('extracts Instagram username', () => { 73 + expect(extractPlatformUsername('instagram', 'https://instagram.com/guido')).toBe('guido'); 74 + }); 75 + 76 + it('extracts YouTube channel ID', () => { 77 + expect(extractPlatformUsername('youtube', 'https://youtube.com/channel/UC1234abc')).toBe( 78 + 'UC1234abc', 79 + ); 80 + }); 81 + 82 + it('extracts YouTube custom handle', () => { 83 + expect(extractPlatformUsername('youtube', 'https://youtube.com/@guido')).toBe('@guido'); 84 + }); 85 + 86 + it('extracts Fediverse username from URL', () => { 87 + expect(extractPlatformUsername('fediverse', 'https://mastodon.social/@guido')).toBe( 88 + '@guido@mastodon.social', 89 + ); 90 + }); 91 + 92 + it('returns null for unknown platform', () => { 93 + expect(extractPlatformUsername('other', 'https://example.com/user')).toBeNull(); 94 + }); 95 + 96 + it('returns null for malformed URL', () => { 97 + expect(extractPlatformUsername('github', 'not-a-url')).toBeNull(); 98 + }); 99 + 100 + it('returns null for GitHub URL without username path', () => { 101 + expect(extractPlatformUsername('github', 'https://github.com')).toBeNull(); 102 + }); 103 + 104 + it('is case-insensitive for platform matching', () => { 105 + expect(extractPlatformUsername('GitHub', 'https://github.com/gxjansen')).toBe('gxjansen'); 106 + }); 107 + }); 108 + });
+267
tests/services/keytrace.test.ts
··· 1 + import { describe, expect, it, vi, beforeEach } from 'vitest'; 2 + import { 3 + fetchKeytraceClaims, 4 + matchClaimsToAccounts, 5 + mergeExternalAccounts, 6 + type KeytraceClaim, 7 + type SifaExternalAccount, 8 + } from '../../src/services/keytrace.js'; 9 + 10 + const { mockListRecords } = vi.hoisted(() => { 11 + const mockListRecords = vi.fn(); 12 + return { mockListRecords }; 13 + }); 14 + 15 + vi.mock('@atproto/api', () => ({ 16 + Agent: class MockAgent { 17 + com = { 18 + atproto: { 19 + repo: { 20 + listRecords: mockListRecords, 21 + }, 22 + }, 23 + }; 24 + }, 25 + })); 26 + 27 + function makeClaim(overrides: Partial<KeytraceClaim> = {}): KeytraceClaim { 28 + return { 29 + rkey: 'claim1', 30 + platform: 'github', 31 + url: 'https://github.com/gxjansen', 32 + claimedAt: '2026-03-15T00:00:00Z', 33 + ...overrides, 34 + }; 35 + } 36 + 37 + function makeSifaAccount(overrides: Partial<SifaExternalAccount> = {}): SifaExternalAccount { 38 + return { 39 + rkey: 'acc1', 40 + platform: 'github', 41 + url: 'https://github.com/gxjansen', 42 + label: null, 43 + feedUrl: null, 44 + isPrimary: false, 45 + verified: false, 46 + verifiedVia: null, 47 + verifiable: true, 48 + ...overrides, 49 + }; 50 + } 51 + 52 + describe('keytrace', () => { 53 + describe('fetchKeytraceClaims', () => { 54 + beforeEach(() => { 55 + vi.clearAllMocks(); 56 + }); 57 + 58 + it('returns cached claims when available', async () => { 59 + const mockValkey = { 60 + get: vi.fn().mockResolvedValue(JSON.stringify([makeClaim()])), 61 + setex: vi.fn(), 62 + }; 63 + 64 + const claims = await fetchKeytraceClaims( 65 + 'did:plc:test', 66 + 'pds.example.com', 67 + mockValkey as never, 68 + ); 69 + expect(claims).toHaveLength(1); 70 + expect(claims[0]?.platform).toBe('github'); 71 + expect(mockValkey.get).toHaveBeenCalledWith('keytrace:claims:did:plc:test'); 72 + }); 73 + 74 + it('returns empty array when PDS fetch fails', async () => { 75 + mockListRecords.mockRejectedValueOnce(new Error('PDS down')); 76 + 77 + const mockValkey = { 78 + get: vi.fn().mockResolvedValue(null), 79 + setex: vi.fn(), 80 + }; 81 + 82 + const claims = await fetchKeytraceClaims( 83 + 'did:plc:test', 84 + 'pds.example.com', 85 + mockValkey as never, 86 + ); 87 + expect(claims).toEqual([]); 88 + }); 89 + 90 + it('returns empty array when valkey is null', async () => { 91 + mockListRecords.mockResolvedValueOnce({ 92 + data: { 93 + records: [ 94 + { 95 + uri: 'at://did:plc:test/dev.keytrace.claim/abc123', 96 + value: { 97 + url: 'https://github.com/gxjansen', 98 + platform: 'github', 99 + createdAt: '2026-03-15T00:00:00Z', 100 + }, 101 + }, 102 + ], 103 + }, 104 + }); 105 + 106 + const claims = await fetchKeytraceClaims('did:plc:test', 'pds.example.com', null); 107 + expect(claims).toHaveLength(1); 108 + expect(claims[0]?.platform).toBe('github'); 109 + }); 110 + 111 + it('caches fetched claims in valkey with 2h TTL', async () => { 112 + mockListRecords.mockResolvedValueOnce({ 113 + data: { 114 + records: [ 115 + { 116 + uri: 'at://did:plc:test/dev.keytrace.claim/abc123', 117 + value: { 118 + url: 'https://github.com/gxjansen', 119 + platform: 'github', 120 + createdAt: '2026-03-15T00:00:00Z', 121 + }, 122 + }, 123 + ], 124 + }, 125 + }); 126 + 127 + const mockValkey = { 128 + get: vi.fn().mockResolvedValue(null), 129 + setex: vi.fn(), 130 + }; 131 + 132 + await fetchKeytraceClaims('did:plc:test', 'pds.example.com', mockValkey as never); 133 + expect(mockValkey.setex).toHaveBeenCalledWith( 134 + 'keytrace:claims:did:plc:test', 135 + 7200, 136 + expect.any(String), 137 + ); 138 + }); 139 + 140 + it('skips malformed records', async () => { 141 + mockListRecords.mockResolvedValueOnce({ 142 + data: { 143 + records: [ 144 + { 145 + uri: 'at://did:plc:test/dev.keytrace.claim/good', 146 + value: { 147 + url: 'https://github.com/gxjansen', 148 + platform: 'github', 149 + createdAt: '2026-03-15T00:00:00Z', 150 + }, 151 + }, 152 + { 153 + uri: 'at://did:plc:test/dev.keytrace.claim/bad', 154 + value: { 155 + // missing url and platform 156 + createdAt: '2026-03-15T00:00:00Z', 157 + }, 158 + }, 159 + ], 160 + }, 161 + }); 162 + 163 + const claims = await fetchKeytraceClaims('did:plc:test', 'pds.example.com', null); 164 + expect(claims).toHaveLength(1); 165 + }); 166 + }); 167 + 168 + describe('matchClaimsToAccounts', () => { 169 + it('matches by exact normalized URL', () => { 170 + const claims = [makeClaim({ url: 'https://github.com/gxjansen/' })]; 171 + const accounts = [makeSifaAccount({ url: 'https://github.com/gxjansen' })]; 172 + 173 + const result = matchClaimsToAccounts(claims, accounts); 174 + expect(result.matched.size).toBe(1); 175 + expect(result.matched.get('acc1')?.rkey).toBe('claim1'); 176 + expect(result.unmatched).toHaveLength(0); 177 + }); 178 + 179 + it('matches by platform + username fallback', () => { 180 + const claims = [makeClaim({ url: 'https://github.com/gxjansen' })]; 181 + const accounts = [ 182 + makeSifaAccount({ url: 'https://www.github.com/gxjansen/repos', rkey: 'acc1' }), 183 + ]; 184 + 185 + const result = matchClaimsToAccounts(claims, accounts); 186 + expect(result.matched.size).toBe(1); 187 + }); 188 + 189 + it('returns unmatched claims when no Sifa account matches', () => { 190 + const claims = [makeClaim({ platform: 'twitter', url: 'https://x.com/guido' })]; 191 + const accounts = [makeSifaAccount({ platform: 'github', url: 'https://github.com/other' })]; 192 + 193 + const result = matchClaimsToAccounts(claims, accounts); 194 + expect(result.matched.size).toBe(0); 195 + expect(result.unmatched).toHaveLength(1); 196 + }); 197 + 198 + it('handles empty claims array', () => { 199 + const result = matchClaimsToAccounts([], [makeSifaAccount()]); 200 + expect(result.matched.size).toBe(0); 201 + expect(result.unmatched).toHaveLength(0); 202 + }); 203 + 204 + it('handles empty accounts array', () => { 205 + const claims = [makeClaim()]; 206 + const result = matchClaimsToAccounts(claims, []); 207 + expect(result.matched.size).toBe(0); 208 + expect(result.unmatched).toHaveLength(1); 209 + }); 210 + }); 211 + 212 + describe('mergeExternalAccounts', () => { 213 + it('marks matched Sifa accounts as keytrace-verified', () => { 214 + const accounts = [makeSifaAccount()]; 215 + const claims = [makeClaim()]; 216 + 217 + const result = mergeExternalAccounts(accounts, claims); 218 + expect(result).toHaveLength(1); 219 + expect(result[0]?.source).toBe('sifa'); 220 + expect(result[0]?.keytraceVerified).toBe(true); 221 + expect(result[0]?.keytraceClaim).toBeDefined(); 222 + expect(result[0]?.keytraceClaim?.rkey).toBe('claim1'); 223 + }); 224 + 225 + it('leaves unmatched Sifa accounts unchanged', () => { 226 + const accounts = [ 227 + makeSifaAccount({ platform: 'linkedin', url: 'https://linkedin.com/in/gxjansen' }), 228 + ]; 229 + const claims = [makeClaim({ platform: 'github', url: 'https://github.com/gxjansen' })]; 230 + 231 + const result = mergeExternalAccounts(accounts, claims); 232 + const sifaEntry = result.find((r) => r.source === 'sifa'); 233 + const keytraceEntry = result.find((r) => r.source === 'keytrace'); 234 + 235 + expect(sifaEntry?.keytraceVerified).toBe(false); 236 + expect(keytraceEntry?.keytraceVerified).toBe(true); 237 + }); 238 + 239 + it('appends unmatched Keytrace claims as keytrace-source entries', () => { 240 + const accounts: SifaExternalAccount[] = []; 241 + const claims = [makeClaim()]; 242 + 243 + const result = mergeExternalAccounts(accounts, claims); 244 + expect(result).toHaveLength(1); 245 + expect(result[0]?.source).toBe('keytrace'); 246 + expect(result[0]?.keytraceVerified).toBe(true); 247 + expect(result[0]?.platform).toBe('github'); 248 + expect(result[0]?.isPrimary).toBe(false); 249 + expect(result[0]?.verified).toBe(false); 250 + expect(result[0]?.verifiedVia).toBeNull(); 251 + }); 252 + 253 + it('returns only Sifa accounts when claims is empty', () => { 254 + const accounts = [makeSifaAccount()]; 255 + 256 + const result = mergeExternalAccounts(accounts, []); 257 + expect(result).toHaveLength(1); 258 + expect(result[0]?.source).toBe('sifa'); 259 + expect(result[0]?.keytraceVerified).toBe(false); 260 + }); 261 + 262 + it('returns empty array when both are empty', () => { 263 + const result = mergeExternalAccounts([], []); 264 + expect(result).toEqual([]); 265 + }); 266 + }); 267 + });