diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 5b5d2ebe..fbf244cf 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -2336,6 +2336,94 @@ export async function federationRoutes(app: FastifyInstance): Promise { }, ); + // ─── POST /api/federation/users/by-home-id ────────────────────────────────── + // Server-to-server: reverse-lookup a homeUserId to its canonical username + + // profile snapshot. Used by the stub-username backfill worker on peers that + // hold legacy snowflake-named replicas of users now visible by their real + // handle. Same auth+rate-limit shape as /users/lookup. + app.post<{ Body: { homeUserId?: unknown } }>( + '/api/federation/users/by-home-id', + { bodyLimit: 4 * 1024 }, + async (request, reply) => { + const db = getDb(); + + // 1. Verify HMAC headers + const fedHeaders = parseFederationHeaders(request.headers as Record); + if (!fedHeaders) { + return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 }); + } + + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, fedHeaders.origin)) + .get(); + + if (!peer || peer.status !== 'active') { + return reply.code(403).send({ error: 'Unknown or inactive peer', statusCode: 403 }); + } + + if (isLookupRateLimited(peer.origin)) { + return reply.code(429).header('Retry-After', '60').send({ error: 'Rate limit exceeded', statusCode: 429 }); + } + + const bodyString = JSON.stringify(request.body); + if (!verifyPeerSignature(bodyString, fedHeaders.signature, fedHeaders.timestamp, fedHeaders.nonce, peer)) { + return reply.code(401).send({ error: 'Invalid signature', statusCode: 401 }); + } + + // Replay protection + if (fedHeaders.nonce) { + if (isNonceDuplicate(peer.origin, fedHeaders.nonce)) { + return reply.code(409).send({ error: 'Duplicate nonce — possible replay', statusCode: 409 }); + } + } else if (peer.nonceSupported) { + return reply.code(401).send({ error: 'Nonce required — peer previously supported nonces', statusCode: 401 }); + } + + // 2. Validate body + const rawId = (request.body as { homeUserId?: unknown } | null)?.homeUserId; + if (typeof rawId !== 'string' || rawId.trim().length === 0) { + return reply.code(400).send({ error: 'homeUserId is required (string)', statusCode: 400 }); + } + const homeUserId = rawId.trim(); + + // 3. Native-only lookup. Match by id (canonical native id) OR home_user_id + // (backfilled column natives carry to satisfy tier-1 lookups). Excludes + // tombstoned and replicated stubs. + const user = db + .select() + .from(schema.users) + .where( + and( + eq(schema.users.isDeleted, 0), + isNull(schema.users.homeInstance), + or(eq(schema.users.id, homeUserId), eq(schema.users.homeUserId, homeUserId)), + ), + ) + .get(); + + if (!user) { + return reply.code(200).send({ found: false }); + } + + return reply.code(200).send({ + found: true, + user: { + homeUserId: user.homeUserId ?? user.id, + username: user.username, + profile: { + displayName: user.displayName, + avatar: user.avatar, + avatarColor: user.avatarColor, + banner: user.banner, + bio: user.bio, + }, + }, + }); + }, + ); + // ─── POST /api/federation/sync ────────────────────────────────────────────── // Server-to-server: checkpoint catch-up sync. A peer calls this after downtime // to retrieve missed DM mutations from the mutation log. diff --git a/packages/server/src/utils/federationLookup.ts b/packages/server/src/utils/federationLookup.ts index d8b81476..8487f942 100644 --- a/packages/server/src/utils/federationLookup.ts +++ b/packages/server/src/utils/federationLookup.ts @@ -75,3 +75,65 @@ export async function lookupRemoteUser(peerOrigin: string, username: string): Pr profile: json.user.profile, }; } + +/** + * Reverse-lookup: ask the peer for a user by homeUserId. Used by the stub + * backfill worker to translate legacy snowflake-named stubs into realname-named + * stubs. Mirrors lookupRemoteUser's auth + error semantics. + * + * `not_found` here means "the peer does not host a native non-deleted user + * with that homeUserId" — including the tombstone case. Caller should leave + * the local stub untouched and retry on the next peer activation. + */ +export async function lookupRemoteUserByHomeId(peerOrigin: string, homeUserId: string): Promise { + const db = getDb(); + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, peerOrigin)) + .get(); + + if (!peer) { + throw new Error(`lookupRemoteUserByHomeId: no peer record for ${peerOrigin}`); + } + + const body = JSON.stringify({ homeUserId }); + const headers = buildFederationHeaders(body, peer.hmacSecret, getOurOrigin()); + + let response: Response; + try { + response = await fetch(`${peerOrigin}/api/federation/users/by-home-id`, { + method: 'POST', + headers, + body, + signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS), + }); + } catch { + return { ok: false, reason: 'unreachable' }; + } + + if (response.status === 429) { + const raw = Number(response.headers.get('Retry-After') ?? '60'); + const retryAfter = Number.isFinite(raw) ? raw : 60; + return { ok: false, reason: 'rate_limited', retryAfter }; + } + + if (!response.ok) { + throw new Error(`lookupRemoteUserByHomeId: peer ${peerOrigin} returned HTTP ${response.status}`); + } + + const json = (await response.json()) as FederationUserLookupResponse; + if (!json) { + throw new Error(`lookupRemoteUserByHomeId: peer ${peerOrigin} returned empty body`); + } + if (json.found !== true || !json.user || typeof json.user.homeUserId !== 'string') { + return { ok: false, reason: 'not_found' }; + } + + return { + ok: true, + homeUserId: json.user.homeUserId, + username: json.user.username, + profile: json.user.profile, + }; +} diff --git a/packages/server/test/federation-by-home-id.test.ts b/packages/server/test/federation-by-home-id.test.ts new file mode 100644 index 00000000..a608371d --- /dev/null +++ b/packages/server/test/federation-by-home-id.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootTwoInstances, type TwoInstanceHarness } from './helpers/twoInstanceHarness.js'; +import { peerInstances } from './helpers/seedPeer.js'; +import { registerLocal } from './helpers/testUsers.js'; +import { buildHeadersForOrigin } from './helpers/hmacSign.js'; + +let harness: TwoInstanceHarness; +let sharedSecret: string; + +beforeAll(async () => { + harness = await bootTwoInstances(); + sharedSecret = await peerInstances(harness.home, harness.remote); +}, 90_000); + +afterAll(async () => { + await harness.cleanup(); +}); + +describe('POST /api/federation/users/by-home-id', () => { + it('returns canonical username + profile for a native user when looked up by homeUserId', async () => { + const target = await registerLocal(harness.remote, 'lookup_target'); + const body = JSON.stringify({ homeUserId: target.id }); + const headers = buildHeadersForOrigin(body, sharedSecret, `https://${harness.home.domain}`); + + const res = await fetch(`${harness.remote.origin}/api/federation/users/by-home-id`, { + method: 'POST', + headers, + body, + }); + expect(res.status).toBe(200); + const json = await res.json() as { found: boolean; user?: { homeUserId: string; username: string; profile: { displayName: string | null } } }; + expect(json.found).toBe(true); + expect(json.user!.homeUserId).toBe(target.id); + expect(json.user!.username).toBe(target.username); + }); + + it('returns { found: false } on unknown homeUserId', async () => { + const body = JSON.stringify({ homeUserId: 'definitely-not-a-real-id' }); + const headers = buildHeadersForOrigin(body, sharedSecret, `https://${harness.home.domain}`); + const res = await fetch(`${harness.remote.origin}/api/federation/users/by-home-id`, { + method: 'POST', + headers, + body, + }); + expect(res.status).toBe(200); + const json = await res.json() as { found: boolean }; + expect(json.found).toBe(false); + }); + + it('rejects unsigned requests with 401', async () => { + const res = await fetch(`${harness.remote.origin}/api/federation/users/by-home-id`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ homeUserId: 'anything' }), + }); + expect(res.status).toBe(401); + }); + + it('rejects non-native targets (replicated stubs) with { found: false }', async () => { + // Pre-existing stub on remote whose homeInstance is non-null. Use the + // first registered native user as a sanity comparison: a homeUserId that + // doesn't match a native non-deleted row → not found. + const stubLikeBody = JSON.stringify({ homeUserId: 'no-such-stub-id' }); + const headers = buildHeadersForOrigin(stubLikeBody, sharedSecret, `https://${harness.home.domain}`); + const res = await fetch(`${harness.remote.origin}/api/federation/users/by-home-id`, { + method: 'POST', + headers, + body: stubLikeBody, + }); + expect(res.status).toBe(200); + const json = await res.json() as { found: boolean }; + expect(json.found).toBe(false); + }); +});