feat(federation): add /users/by-home-id reverse lookup for stub backfill
HMAC-authenticated, rate-limited (60/min/peer) endpoint that resolves a homeUserId on this instance to its canonical username + profile snapshot. Native non-deleted users only. Mirrors /users/lookup's auth shape. Adds lookupRemoteUserByHomeId to federationLookup.ts as the client-side helper. Used by the upcoming stub-backfill worker on peers that hold legacy snowflake-named replicas of users now visible by their real handle.
This commit is contained in:
@@ -2336,6 +2336,94 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── 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<string, string | string[] | undefined>);
|
||||||
|
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 ──────────────────────────────────────────────
|
// ─── POST /api/federation/sync ──────────────────────────────────────────────
|
||||||
// Server-to-server: checkpoint catch-up sync. A peer calls this after downtime
|
// Server-to-server: checkpoint catch-up sync. A peer calls this after downtime
|
||||||
// to retrieve missed DM mutations from the mutation log.
|
// to retrieve missed DM mutations from the mutation log.
|
||||||
|
|||||||
@@ -75,3 +75,65 @@ export async function lookupRemoteUser(peerOrigin: string, username: string): Pr
|
|||||||
profile: json.user.profile,
|
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<LookupResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user