refactor(server): split federation routes into cohesive modules (#9)
routes/federation.ts had grown to 7.6k lines, spanning HTTP route registration, federated identity resolution, ~30 inbound relay event processors, DM reconciliation, and rate-limiting internals — too large to review or hold in context, and awkward to change safely. Split the implementation into 18 focused modules under routes/federation/ (helpers, events/, handlers/) and keep routes/federation.ts as a thin barrel that re-exports the public API and composes the HTTP registrars into federationRoutes(). No import paths change anywhere else. Pure move, no behavior change: - 61/61 named functions byte-identical; only deltas are 2 dynamic-import paths adjusted for the new directory depth - public export surface unchanged (barrel re-exports all 22 symbols) - all 30 endpoints preserved (identical verb+path set) - typecheck, build, and full server suite (790 tests) green Docs: update federation.md source-file map; add split design doc.
This commit is contained in:
@@ -0,0 +1,890 @@
|
||||
import path from 'node:path';
|
||||
import { getDb, schema } from '../../../db/index.js';
|
||||
import { authenticate, requireAdmin } from '../../../utils/auth.js';
|
||||
import { buildFederationHeaders, generateHmacSecret, getOurOrigin } from '../../../utils/federationAuth.js';
|
||||
import { getInstanceId } from '../../../utils/federationEpoch.js';
|
||||
import { onPeerActivated } from '../../../utils/federationPeerActivation.js';
|
||||
import { generateSnowflake } from '../../../utils/snowflake.js';
|
||||
import { connectionManager } from '../../../ws/handler.js';
|
||||
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { ApprovalRequestSubscriberSummary, PeeringTriggerReason } from '@backspace/shared';
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { resolveLocalOrigin, sanitizePeer } from '../origin.js';
|
||||
|
||||
/**
|
||||
* Queue an inbound peer/accept request for local-admin approval.
|
||||
*
|
||||
* Called from `/peer/accept` when:
|
||||
* (a) `autoAcceptPeering=0` and no `pending`/`awaiting_approval` peer row
|
||||
* exists for the source origin (first-contact request from remote), OR
|
||||
* (b) the receiver is in `awaiting_approval` for this origin but the
|
||||
* inbound `/peer/accept` cannot be cryptographically verified
|
||||
* (token absent or mismatched) — see spec §3.5.
|
||||
*
|
||||
* Generates a fresh single-use approval token, upserts the
|
||||
* `peer_approval_requests` row, notifies admins, and returns 202 with the
|
||||
* token in the body. The initiator stores the token alongside its
|
||||
* `awaiting_approval` row so a future `/peer/accept` from this side's
|
||||
* `/approve` endpoint can verify mutual admin approval.
|
||||
*/
|
||||
export function queueApprovalRequest(
|
||||
db: ReturnType<typeof getDb>,
|
||||
reply: FastifyReply,
|
||||
sourceOrigin: string,
|
||||
hmacSecret: string,
|
||||
reqInstanceName: string | null,
|
||||
): FastifyReply {
|
||||
const now = Date.now();
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const approvalToken = randomBytes(32).toString('hex');
|
||||
|
||||
const existingRequest = db
|
||||
.select({ id: schema.peerApprovalRequests.id })
|
||||
.from(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
|
||||
.get();
|
||||
|
||||
if (existingRequest) {
|
||||
db.update(schema.peerApprovalRequests)
|
||||
.set({
|
||||
instanceName: reqInstanceName,
|
||||
hmacSecret,
|
||||
requestedAt: now,
|
||||
expiresAt: now + THIRTY_DAYS_MS,
|
||||
approvalToken,
|
||||
})
|
||||
.where(eq(schema.peerApprovalRequests.id, existingRequest.id))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(schema.peerApprovalRequests)
|
||||
.values({
|
||||
id: generateSnowflake(),
|
||||
origin: sourceOrigin,
|
||||
instanceName: reqInstanceName,
|
||||
hmacSecret,
|
||||
requestedAt: now,
|
||||
expiresAt: now + THIRTY_DAYS_MS,
|
||||
approvalToken,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
connectionManager.sendToAdmins({
|
||||
type: 'federation_approval_request_received' as const,
|
||||
origin: sourceOrigin,
|
||||
instanceName: reqInstanceName ?? undefined,
|
||||
});
|
||||
|
||||
return reply.code(202).send({
|
||||
queued: true,
|
||||
message: 'Request queued for admin approval',
|
||||
approvalToken,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Inbound approve — admin accepts a remote instance's peering request.
|
||||
* Generates fresh HMAC, sends `/peer/accept` to the remote, and on success
|
||||
* activates the peer locally. Preserves the historical behavior verbatim;
|
||||
* extracted from the route handler so the dispatcher can branch on direction.
|
||||
*/
|
||||
export async function handleInboundApprove(
|
||||
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
|
||||
localOrigin: string,
|
||||
reply: FastifyReply,
|
||||
): Promise<FastifyReply> {
|
||||
const db = getDb();
|
||||
const id = approvalReq.id;
|
||||
|
||||
const existingPeer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.origin, approvalReq.origin))
|
||||
.get();
|
||||
|
||||
if (existingPeer && existingPeer.status === 'active') {
|
||||
db.delete(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.id, id))
|
||||
.run();
|
||||
return reply.code(200).send({ success: true, peer: sanitizePeer(existingPeer) });
|
||||
}
|
||||
|
||||
if (existingPeer) {
|
||||
db.delete(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, existingPeer.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
const hmacSecret = generateHmacSecret();
|
||||
const peerId = generateSnowflake();
|
||||
const now = Date.now();
|
||||
|
||||
db.insert(schema.federationPeers).values({
|
||||
id: peerId,
|
||||
origin: approvalReq.origin,
|
||||
instanceName: approvalReq.instanceName,
|
||||
hmacSecret,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
try {
|
||||
const instanceName = db
|
||||
.select({ name: schema.instanceSettings.instanceName })
|
||||
.from(schema.instanceSettings)
|
||||
.where(eq(schema.instanceSettings.id, 1))
|
||||
.get()?.name ?? undefined;
|
||||
|
||||
const response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceOrigin: localOrigin,
|
||||
hmacSecret,
|
||||
instanceName,
|
||||
instanceId: getInstanceId(),
|
||||
// Forward the stored token (issued in our 202 response when the
|
||||
// remote first sent /peer/accept). Lets the remote verify mutual
|
||||
// admin approval. Spec §3.7.
|
||||
...(approvalReq.approvalToken ? { approvalToken: approvalReq.approvalToken } : {}),
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (response.status === 202) {
|
||||
// Remote instance also has autoAcceptPeering off — they queued our request.
|
||||
// Don't activate our peer. Set to awaiting_approval until their admin also approves.
|
||||
// Capture the approval token they returned so the next inbound
|
||||
// /peer/accept (when their admin approves) can be verified. §3.7.
|
||||
let returnedToken: string | null = null;
|
||||
try {
|
||||
const body = (await response.json()) as { approvalToken?: string };
|
||||
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
|
||||
returnedToken = body.approvalToken;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON / empty body — legacy peer.
|
||||
}
|
||||
|
||||
db.update(schema.federationPeers)
|
||||
.set({ status: 'awaiting_approval', approvalToken: returnedToken })
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.run();
|
||||
// Delete the approval request since we already acted on it
|
||||
db.delete(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.id, id))
|
||||
.run();
|
||||
return reply.code(200).send({
|
||||
success: true,
|
||||
awaitingRemoteApproval: true,
|
||||
message: 'Remote instance also requires admin approval. Your request has been queued on their side.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`;
|
||||
try {
|
||||
const body = await response.json() as { error?: string };
|
||||
if (body.error) errorMessage = body.error;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
db.delete(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.run();
|
||||
return reply.code(502).send({ error: errorMessage, statusCode: 502 });
|
||||
}
|
||||
|
||||
// Parse the remote's instanceName and instanceId (epoch) from the response
|
||||
// body so the federation panel renders a friendly label and we record the
|
||||
// peer's authenticated epoch baseline. Tolerate omission and non-JSON
|
||||
// bodies — same pattern as performHandshake and /peer/initiate.
|
||||
let remoteInstanceName: string | null = null;
|
||||
let remoteInstanceId: string | null = null;
|
||||
try {
|
||||
const body = (await response.json()) as { instanceName?: string | null; instanceId?: string | null };
|
||||
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
|
||||
remoteInstanceName = body.instanceName;
|
||||
}
|
||||
if (typeof body?.instanceId === 'string' && body.instanceId.length > 0) {
|
||||
remoteInstanceId = body.instanceId;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON body — leave null.
|
||||
}
|
||||
|
||||
db.update(schema.federationPeers)
|
||||
.set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, peerInstanceId: remoteInstanceId, approvalToken: null })
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.run();
|
||||
|
||||
db.delete(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.id, id))
|
||||
.run();
|
||||
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
onPeerActivated(peerId, 'approval_handshake').catch(err =>
|
||||
console.error('[federation] onPeerActivated from /approval-requests/:id/approve failed:', err)
|
||||
);
|
||||
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.get();
|
||||
|
||||
return reply.code(200).send({ success: true, peer: peer ? sanitizePeer(peer) : undefined });
|
||||
} catch (err: unknown) {
|
||||
db.delete(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.run();
|
||||
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||
return reply.code(504).send({
|
||||
error: 'Remote instance did not respond within 10 seconds',
|
||||
statusCode: 504,
|
||||
});
|
||||
}
|
||||
return reply.code(502).send({
|
||||
error: `Failed to reach remote instance: ${message}`,
|
||||
statusCode: 502,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Outbound approve — admin authorizes the local instance to peer with a
|
||||
* remote that one or more of its users have requested. Generates fresh HMAC,
|
||||
* sends `/peer/accept` to the remote, and:
|
||||
* - 200 → activate peer; `onPeerActivated` runs and (per Task 6) fans out
|
||||
* approved-notifications to outbound subscribers and cascade-deletes the
|
||||
* queue row. The handler MUST NOT duplicate that cleanup.
|
||||
* - 202 → remote also gates; transition to `awaiting_approval`, capture
|
||||
* the returned token, leave the queue row + subscribers untouched (they
|
||||
* wait for the remote admin to approve and the eventual full activation
|
||||
* to fan out via `onPeerActivated`).
|
||||
* - 4xx/5xx/network → clean up the peer row we created; leave the queue
|
||||
* row alone so the admin can retry.
|
||||
*/
|
||||
export async function handleOutboundApprove(
|
||||
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
|
||||
localOrigin: string,
|
||||
reply: FastifyReply,
|
||||
): Promise<FastifyReply> {
|
||||
const db = getDb();
|
||||
const hmacSecret = generateHmacSecret();
|
||||
const peerId = generateSnowflake();
|
||||
const now = Date.now();
|
||||
|
||||
// Insert the peer row in 'pending' so failure paths roll back cleanly.
|
||||
db.insert(schema.federationPeers).values({
|
||||
id: peerId,
|
||||
origin: approvalReq.origin,
|
||||
instanceName: approvalReq.instanceName,
|
||||
hmacSecret,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
const instanceName = db
|
||||
.select({ name: schema.instanceSettings.instanceName })
|
||||
.from(schema.instanceSettings)
|
||||
.where(eq(schema.instanceSettings.id, 1))
|
||||
.get()?.name ?? undefined;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceOrigin: localOrigin,
|
||||
hmacSecret,
|
||||
instanceName,
|
||||
instanceId: getInstanceId(),
|
||||
// No approvalToken — outbound rows are admin-initiated locally; we
|
||||
// hold no prior token from the remote and rely on the remote's own
|
||||
// autoAcceptPeering setting to decide 200 vs 202.
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
db.delete(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.run();
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||
return reply.code(504).send({
|
||||
error: 'Remote instance did not respond within 10 seconds',
|
||||
statusCode: 504,
|
||||
});
|
||||
}
|
||||
return reply.code(503).send({
|
||||
error: `Remote instance unreachable: ${message}`,
|
||||
statusCode: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 202) {
|
||||
// Remote also gates new peers. Capture the approval token they returned
|
||||
// so the next inbound /peer/accept (when the remote admin approves) can
|
||||
// verify mutual admin approval. The outbound queue row and its
|
||||
// subscribers REMAIN — `onPeerActivated` is NOT called here; subscribers
|
||||
// wait for the eventual activation (fanout happens then).
|
||||
let returnedToken: string | null = null;
|
||||
try {
|
||||
const body = (await response.json()) as { approvalToken?: string };
|
||||
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
|
||||
returnedToken = body.approvalToken;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON / empty body — legacy peer with no token to capture.
|
||||
}
|
||||
|
||||
db.update(schema.federationPeers)
|
||||
.set({ status: 'awaiting_approval', approvalToken: returnedToken })
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.run();
|
||||
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.get();
|
||||
|
||||
return reply.code(200).send({
|
||||
success: true,
|
||||
peerStatus: 'awaiting_approval' as const,
|
||||
awaitingRemoteApproval: true,
|
||||
message: 'Remote instance also requires admin approval. Your request has been queued on their side.',
|
||||
peer: peer ? sanitizePeer(peer) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`;
|
||||
try {
|
||||
const body = await response.json() as { error?: string };
|
||||
if (body.error) errorMessage = body.error;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Clean up the peer row we created. Leave the outbound queue row alone
|
||||
// so the admin can retry without re-collecting subscribers.
|
||||
db.delete(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.run();
|
||||
return reply.code(502).send({
|
||||
error: errorMessage,
|
||||
statusCode: 502,
|
||||
remoteStatus: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
// 200 — peer activated. Capture remote's instanceName for the friendly label
|
||||
// and instanceId (epoch) for the authenticated baseline.
|
||||
let remoteInstanceName: string | null = approvalReq.instanceName;
|
||||
let remoteInstanceId: string | null = null;
|
||||
try {
|
||||
const body = (await response.json()) as { instanceName?: string | null; instanceId?: string | null };
|
||||
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
|
||||
remoteInstanceName = body.instanceName;
|
||||
}
|
||||
if (typeof body?.instanceId === 'string' && body.instanceId.length > 0) {
|
||||
remoteInstanceId = body.instanceId;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON body — keep approvalReq.instanceName (may be null).
|
||||
}
|
||||
|
||||
db.update(schema.federationPeers)
|
||||
.set({
|
||||
status: 'active',
|
||||
lastSeenAt: now,
|
||||
instanceName: remoteInstanceName,
|
||||
peerInstanceId: remoteInstanceId,
|
||||
approvalToken: null,
|
||||
})
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.run();
|
||||
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
|
||||
// onPeerActivated runs fanoutOutboundSubscribers (Task 6) which:
|
||||
// - inserts kind='approved' notifications for each subscriber,
|
||||
// - sends `peering_notification_received` WS to each subscriber,
|
||||
// - cascade-deletes the parent + subscriber rows.
|
||||
// Do NOT duplicate any of that here — it would double-notify and corrupt
|
||||
// the queue.
|
||||
onPeerActivated(peerId, 'approval_handshake').catch(err =>
|
||||
console.error('[federation] onPeerActivated from outbound /approval-requests/:id/approve failed:', err)
|
||||
);
|
||||
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.get();
|
||||
|
||||
return reply.code(200).send({
|
||||
success: true,
|
||||
peerStatus: 'active' as const,
|
||||
peer: peer ? sanitizePeer(peer) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Inbound deny — admin rejects a remote instance's peering request. Fires
|
||||
* the existing /peer/denied notification to the remote, marks any local
|
||||
* peer row as `rejected`, and clears the queue row. Preserves historical
|
||||
* behavior verbatim.
|
||||
*/
|
||||
export async function handleInboundDeny(
|
||||
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
|
||||
reply: FastifyReply,
|
||||
): Promise<FastifyReply> {
|
||||
const db = getDb();
|
||||
const id = approvalReq.id;
|
||||
|
||||
// Inbound rows always carry hmacSecret (CHECK constraint enforces this).
|
||||
// If it's somehow null, we cannot sign /peer/denied — surface clearly.
|
||||
if (!approvalReq.hmacSecret) {
|
||||
return reply.code(500).send({
|
||||
error: 'Inbound approval request is missing hmacSecret — cannot deliver /peer/denied notification.',
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
const ourOrigin = getOurOrigin();
|
||||
const denialBody = JSON.stringify({
|
||||
origin: ourOrigin,
|
||||
reason: 'denied_by_admin' as const,
|
||||
message: 'Request denied by admin',
|
||||
});
|
||||
|
||||
const headers = buildFederationHeaders(denialBody, approvalReq.hmacSecret, ourOrigin);
|
||||
|
||||
let notificationSent = false;
|
||||
try {
|
||||
const response = await fetch(`${approvalReq.origin}/api/federation/peer/denied`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: denialBody,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
notificationSent = response.ok;
|
||||
} catch {
|
||||
// Network error
|
||||
}
|
||||
|
||||
if (!notificationSent) {
|
||||
return reply.code(502).send({
|
||||
error: 'Denial notification could not be delivered to the remote instance. The request is still pending — you can retry or wait for it to expire.',
|
||||
statusCode: 502,
|
||||
});
|
||||
}
|
||||
|
||||
const existingPeer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.origin, approvalReq.origin))
|
||||
.get();
|
||||
|
||||
if (!existingPeer) {
|
||||
db.insert(schema.federationPeers).values({
|
||||
id: generateSnowflake(),
|
||||
origin: approvalReq.origin,
|
||||
instanceName: approvalReq.instanceName,
|
||||
hmacSecret: approvalReq.hmacSecret,
|
||||
status: 'rejected',
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
} else if (existingPeer.status !== 'active') {
|
||||
db.update(schema.federationPeers)
|
||||
.set({ status: 'rejected' })
|
||||
.where(eq(schema.federationPeers.id, existingPeer.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
|
||||
db.delete(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.id, id))
|
||||
.run();
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Outbound deny — admin refuses local users' peering request. Fans out
|
||||
* `kind='denied'` notifications to each subscriber and cascade-deletes the
|
||||
* parent (which clears subscribers via FK cascade). No remote network call
|
||||
* — outbound rows have no /peer/denied counterpart on the wire (the remote
|
||||
* never knew we were considering this).
|
||||
*/
|
||||
export async function handleOutboundDeny(
|
||||
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
|
||||
reply: FastifyReply,
|
||||
): Promise<FastifyReply> {
|
||||
const db = getDb();
|
||||
const subscribers = db
|
||||
.select()
|
||||
.from(schema.peerApprovalSubscribers)
|
||||
.where(eq(schema.peerApprovalSubscribers.requestId, approvalReq.id))
|
||||
.all();
|
||||
|
||||
const now = Date.now();
|
||||
for (const sub of subscribers) {
|
||||
db.insert(schema.peerApprovalNotifications)
|
||||
.values({
|
||||
id: generateSnowflake(),
|
||||
userId: sub.userId,
|
||||
kind: 'denied',
|
||||
peerOrigin: approvalReq.origin,
|
||||
triggerReason: sub.triggerReason,
|
||||
triggerTarget: sub.triggerTarget,
|
||||
createdAt: now,
|
||||
readAt: null,
|
||||
})
|
||||
.run();
|
||||
|
||||
connectionManager.sendToUser(sub.userId, {
|
||||
type: 'peering_notification_received' as const,
|
||||
kind: 'denied',
|
||||
});
|
||||
// Subscriber row is about to cascade-delete; refresh the user's pending list.
|
||||
connectionManager.sendToUser(sub.userId, {
|
||||
type: 'peering_subscription_changed' as const,
|
||||
});
|
||||
}
|
||||
|
||||
// Cascade-delete clears subscribers via onDelete: 'cascade'.
|
||||
db.delete(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.id, approvalReq.id))
|
||||
.run();
|
||||
|
||||
// Tell admins the queue changed.
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
|
||||
if (subscribers.length > 0) {
|
||||
console.log(
|
||||
`[federation] handleOutboundDeny denied ${subscribers.length} subscriber notification${subscribers.length === 1 ? '' : 's'} for ${approvalReq.origin}`,
|
||||
);
|
||||
}
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
}
|
||||
|
||||
|
||||
export function registerApprovalRoutes(app: FastifyInstance): void {
|
||||
// ─── GET /api/federation/approval-requests ─────────────────────────────────
|
||||
app.get(
|
||||
'/api/federation/approval-requests',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (_request, reply) => {
|
||||
const db = getDb();
|
||||
const requests = db
|
||||
.select({
|
||||
id: schema.peerApprovalRequests.id,
|
||||
origin: schema.peerApprovalRequests.origin,
|
||||
direction: schema.peerApprovalRequests.direction,
|
||||
instanceName: schema.peerApprovalRequests.instanceName,
|
||||
requestedAt: schema.peerApprovalRequests.requestedAt,
|
||||
expiresAt: schema.peerApprovalRequests.expiresAt,
|
||||
})
|
||||
.from(schema.peerApprovalRequests)
|
||||
.orderBy(desc(schema.peerApprovalRequests.requestedAt))
|
||||
.all();
|
||||
|
||||
// For outbound rows, fetch subscriber summaries (joined with users for username).
|
||||
// Inbound rows have no subscriber concept; field is omitted in their response.
|
||||
const outboundIds = requests.filter(r => r.direction === 'outbound').map(r => r.id);
|
||||
const subscribersByRequestId = new Map<string, ApprovalRequestSubscriberSummary[]>();
|
||||
if (outboundIds.length > 0) {
|
||||
const rows = db
|
||||
.select({
|
||||
requestId: schema.peerApprovalSubscribers.requestId,
|
||||
userId: schema.peerApprovalSubscribers.userId,
|
||||
username: schema.users.username,
|
||||
triggerReason: schema.peerApprovalSubscribers.triggerReason,
|
||||
triggerTarget: schema.peerApprovalSubscribers.triggerTarget,
|
||||
})
|
||||
.from(schema.peerApprovalSubscribers)
|
||||
.innerJoin(schema.users, eq(schema.users.id, schema.peerApprovalSubscribers.userId))
|
||||
.where(inArray(schema.peerApprovalSubscribers.requestId, outboundIds))
|
||||
.all();
|
||||
for (const row of rows) {
|
||||
const arr = subscribersByRequestId.get(row.requestId) ?? [];
|
||||
arr.push({
|
||||
userId: row.userId,
|
||||
username: row.username,
|
||||
triggerReason: row.triggerReason as PeeringTriggerReason,
|
||||
triggerTarget: row.triggerTarget,
|
||||
});
|
||||
subscribersByRequestId.set(row.requestId, arr);
|
||||
}
|
||||
}
|
||||
|
||||
return reply.code(200).send({
|
||||
requests: requests.map(r =>
|
||||
r.direction === 'outbound'
|
||||
? { ...r, subscribers: subscribersByRequestId.get(r.id) ?? [] }
|
||||
: r,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/approval-requests/:id/approve ───────────────────
|
||||
// Direction-branched: inbound rows complete the existing accept-handshake
|
||||
// path (preserved verbatim); outbound rows initiate /peer/accept against
|
||||
// the remote, capturing 200/202 outcomes and leaving the queue intact on
|
||||
// failure so the admin can retry.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/federation/approval-requests/:id/approve',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const { id } = request.params;
|
||||
|
||||
const approvalReq = db
|
||||
.select()
|
||||
.from(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.id, id))
|
||||
.get();
|
||||
|
||||
if (!approvalReq) {
|
||||
return reply.code(404).send({ error: 'Approval request not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
let localOrigin: string;
|
||||
try {
|
||||
localOrigin = resolveLocalOrigin();
|
||||
} catch {
|
||||
return reply.code(500).send({
|
||||
error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.',
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
if (approvalReq.direction === 'outbound') {
|
||||
return await handleOutboundApprove(approvalReq, localOrigin, reply);
|
||||
}
|
||||
|
||||
return await handleInboundApprove(approvalReq, localOrigin, reply);
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/approval-requests/:id/deny ───────────────────────
|
||||
// Direction-branched: inbound rows hit the remote's /peer/denied endpoint
|
||||
// (existing behavior preserved); outbound rows fan out denied notifications
|
||||
// to subscribers and cascade-delete the queue row.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/federation/approval-requests/:id/deny',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const { id } = request.params;
|
||||
|
||||
const approvalReq = db
|
||||
.select()
|
||||
.from(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.id, id))
|
||||
.get();
|
||||
|
||||
if (!approvalReq) {
|
||||
return reply.code(404).send({ error: 'Approval request not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (approvalReq.direction === 'outbound') {
|
||||
return await handleOutboundDeny(approvalReq, reply);
|
||||
}
|
||||
|
||||
return await handleInboundDeny(approvalReq, reply);
|
||||
},
|
||||
);
|
||||
|
||||
// ─── GET /api/federation/peering-subscriptions ─────────────────────────────
|
||||
// User-facing: list the requesting user's pending outbound peering
|
||||
// subscriber rows joined to their parent peer_approval_requests. Used by the
|
||||
// pending-peering UI surface to show "you have a peering with X waiting on
|
||||
// your admin's approval" rows.
|
||||
app.get(
|
||||
'/api/federation/peering-subscriptions',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const userId = request.userId;
|
||||
const rows = db
|
||||
.select({
|
||||
id: schema.peerApprovalSubscribers.id,
|
||||
requestId: schema.peerApprovalSubscribers.requestId,
|
||||
peerOrigin: schema.peerApprovalRequests.origin,
|
||||
peerInstanceName: schema.peerApprovalRequests.instanceName,
|
||||
triggerReason: schema.peerApprovalSubscribers.triggerReason,
|
||||
triggerTarget: schema.peerApprovalSubscribers.triggerTarget,
|
||||
createdAt: schema.peerApprovalSubscribers.createdAt,
|
||||
})
|
||||
.from(schema.peerApprovalSubscribers)
|
||||
.innerJoin(
|
||||
schema.peerApprovalRequests,
|
||||
eq(schema.peerApprovalRequests.id, schema.peerApprovalSubscribers.requestId),
|
||||
)
|
||||
.where(eq(schema.peerApprovalSubscribers.userId, userId))
|
||||
.orderBy(desc(schema.peerApprovalSubscribers.createdAt))
|
||||
.all();
|
||||
return reply.send({ subscriptions: rows });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── DELETE /api/federation/peering-subscriptions/:id ──────────────────────
|
||||
// User-facing: cancel one of the requesting user's pending peering
|
||||
// subscriptions. Authorization: subscriber.userId must match request.userId.
|
||||
// If this was the last subscriber for its parent request, the parent
|
||||
// cascade-deletes too (avoids zombie outbound rows in the admin queue).
|
||||
// No notification is created for the canceller (per spec §4.3 (iii)).
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/federation/peering-subscriptions/:id',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const { id } = request.params;
|
||||
const userId = request.userId;
|
||||
|
||||
const sub = db
|
||||
.select()
|
||||
.from(schema.peerApprovalSubscribers)
|
||||
.where(eq(schema.peerApprovalSubscribers.id, id))
|
||||
.get();
|
||||
if (!sub) {
|
||||
return reply.code(404).send({ error: 'subscription_not_found', statusCode: 404 });
|
||||
}
|
||||
if (sub.userId !== userId) {
|
||||
return reply.code(403).send({ error: 'forbidden', statusCode: 403 });
|
||||
}
|
||||
|
||||
db.delete(schema.peerApprovalSubscribers)
|
||||
.where(eq(schema.peerApprovalSubscribers.id, id))
|
||||
.run();
|
||||
|
||||
// If the row we just removed was the last subscriber on its parent
|
||||
// peer_approval_request, cascade-delete the parent. The admin queue
|
||||
// refreshes via federation_peers_changed.
|
||||
const remaining = db
|
||||
.select({ id: schema.peerApprovalSubscribers.id })
|
||||
.from(schema.peerApprovalSubscribers)
|
||||
.where(eq(schema.peerApprovalSubscribers.requestId, sub.requestId))
|
||||
.all();
|
||||
if (remaining.length === 0) {
|
||||
db.delete(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.id, sub.requestId))
|
||||
.run();
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
}
|
||||
|
||||
connectionManager.sendToUser(userId, { type: 'peering_subscription_changed' as const });
|
||||
return reply.send({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── GET /api/federation/peering-notifications ─────────────────────────────
|
||||
// User-facing: list the requesting user's terminal-state peering
|
||||
// notifications (kind='approved'|'denied'|'expired'). Optional ?unread=1
|
||||
// filter narrows to rows where readAt IS NULL. Ordered DESC by createdAt
|
||||
// (newest first).
|
||||
app.get<{ Querystring: { unread?: string } }>(
|
||||
'/api/federation/peering-notifications',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const userId = request.userId;
|
||||
const unread = request.query?.unread === '1';
|
||||
|
||||
const whereClause = unread
|
||||
? and(
|
||||
eq(schema.peerApprovalNotifications.userId, userId),
|
||||
isNull(schema.peerApprovalNotifications.readAt),
|
||||
)
|
||||
: eq(schema.peerApprovalNotifications.userId, userId);
|
||||
|
||||
const notifications = db
|
||||
.select({
|
||||
id: schema.peerApprovalNotifications.id,
|
||||
kind: schema.peerApprovalNotifications.kind,
|
||||
peerOrigin: schema.peerApprovalNotifications.peerOrigin,
|
||||
triggerReason: schema.peerApprovalNotifications.triggerReason,
|
||||
triggerTarget: schema.peerApprovalNotifications.triggerTarget,
|
||||
createdAt: schema.peerApprovalNotifications.createdAt,
|
||||
readAt: schema.peerApprovalNotifications.readAt,
|
||||
})
|
||||
.from(schema.peerApprovalNotifications)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(schema.peerApprovalNotifications.createdAt))
|
||||
.all();
|
||||
|
||||
return reply.send({ notifications });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/peering-notifications/:id/read ───────────────────
|
||||
// User-facing: mark a single peering notification as read. Authorization:
|
||||
// notification.userId must match request.userId.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/federation/peering-notifications/:id/read',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const { id } = request.params;
|
||||
const userId = request.userId;
|
||||
|
||||
const notif = db
|
||||
.select()
|
||||
.from(schema.peerApprovalNotifications)
|
||||
.where(eq(schema.peerApprovalNotifications.id, id))
|
||||
.get();
|
||||
if (!notif) {
|
||||
return reply.code(404).send({ error: 'notification_not_found', statusCode: 404 });
|
||||
}
|
||||
if (notif.userId !== userId) {
|
||||
return reply.code(403).send({ error: 'forbidden', statusCode: 403 });
|
||||
}
|
||||
|
||||
db.update(schema.peerApprovalNotifications)
|
||||
.set({ readAt: Date.now() })
|
||||
.where(eq(schema.peerApprovalNotifications.id, id))
|
||||
.run();
|
||||
return reply.send({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/peering-notifications/read-all ───────────────────
|
||||
// User-facing: mark all the requesting user's unread peering notifications
|
||||
// as read. Already-read rows are NOT touched (their readAt is preserved).
|
||||
// Returns the count of rows affected for UI feedback.
|
||||
app.post(
|
||||
'/api/federation/peering-notifications/read-all',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const userId = request.userId;
|
||||
const result = db
|
||||
.update(schema.peerApprovalNotifications)
|
||||
.set({ readAt: Date.now() })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.peerApprovalNotifications.userId, userId),
|
||||
isNull(schema.peerApprovalNotifications.readAt),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
return reply.send({ success: true, count: result.changes });
|
||||
},
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import path from 'node:path';
|
||||
import { config } from '../../../config.js';
|
||||
import { getDb, getRawDb, schema } from '../../../db/index.js';
|
||||
import { authenticate } from '../../../utils/auth.js';
|
||||
import { fetchHomeProfileByHomeId, verifyAttachProofWithPeer } from '../../../utils/federationAttach.js';
|
||||
import { buildFederationHeaders, getOurOrigin, parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js';
|
||||
import { sanitizeUser } from '../../../utils/sanitize.js';
|
||||
import { collectProfileBroadcastTargetIds } from '../../../utils/userDeletion.js';
|
||||
import { connectionManager } from '../../../ws/handler.js';
|
||||
import { and, eq, isNull, or } from 'drizzle-orm';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { DmChannel } from '@backspace/shared';
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { buildDmChannelPayload } from '../dmChannels.js';
|
||||
import { extractDomain } from '../identity.js';
|
||||
import { downloadProfileAsset } from '../profile.js';
|
||||
import { isLookupRateLimited, isNonceDuplicate } from '../rateLimits.js';
|
||||
import { reconcileDmChannelFederatedId } from '../reconciliation.js';
|
||||
import type { DmReconcileResult } from '../reconciliation.js';
|
||||
|
||||
export function registerAttachRoutes(app: FastifyInstance): void {
|
||||
// ─── POST /api/federation/verify-attach-proof ───────────────────────────────
|
||||
// Server-to-server: verify a one-time attach-proof token minted by
|
||||
// /api/auth/attach-proof (re-attach spec §3.1). The token is single-use (an
|
||||
// atomic claim guarantees only one concurrent verification can win) and is
|
||||
// bound to the CALLING peer's domain — the binding is checked against the
|
||||
// authenticated peer row (extractDomain(peer.origin)), NEVER trusted from the
|
||||
// request body. This is the anti-replay control: a compromised requester
|
||||
// cannot redeem a token minted for a different peer. The response is HMAC-
|
||||
// signed (epoch pattern) so the caller can trust the identity it carries; all
|
||||
// failure modes fail closed to a signed { valid: false }.
|
||||
app.post<{ Body: { token?: unknown } }>(
|
||||
'/api/federation/verify-attach-proof',
|
||||
{ bodyLimit: 4 * 1024 },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const rawDb = getRawDb();
|
||||
|
||||
// 1. Verify HMAC headers (mirror by-home-id / users-lookup).
|
||||
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. Sign every downstream response with the peer's shared secret so the
|
||||
// caller can trust the identity (or the fail-closed verdict) it carries.
|
||||
const sendSigned = (payload: { valid: false } | { valid: true; homeUserId: string; username: string }): FastifyReply => {
|
||||
const responseBody = JSON.stringify(payload);
|
||||
const sigHeaders = buildFederationHeaders(responseBody, peer.hmacSecret, getOurOrigin());
|
||||
reply.headers({
|
||||
'X-Federation-Signature': sigHeaders['X-Federation-Signature'],
|
||||
'X-Federation-Timestamp': sigHeaders['X-Federation-Timestamp'],
|
||||
'X-Federation-Nonce': sigHeaders['X-Federation-Nonce'],
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
return reply.code(200).send(responseBody);
|
||||
};
|
||||
|
||||
// 3. Validate the token shape (64 hex chars, as minted by attach-proof).
|
||||
const rawToken = (request.body as { token?: unknown } | null)?.token;
|
||||
if (typeof rawToken !== 'string' || !/^[0-9a-f]{64}$/i.test(rawToken)) {
|
||||
return sendSigned({ valid: false });
|
||||
}
|
||||
|
||||
// 4. Atomic single-use claim. The domain binding is server-side: the
|
||||
// token's target_domain must equal the AUTHENTICATED peer's domain, never
|
||||
// a value from the request body. Concurrent verifications cannot both win
|
||||
// because only the first UPDATE that flips used_at from NULL matches.
|
||||
const peerDomain = extractDomain(peer.origin).toLowerCase();
|
||||
const now = Date.now();
|
||||
const claimed = rawDb.prepare(`
|
||||
UPDATE federation_attach_proofs
|
||||
SET used_at = ?
|
||||
WHERE token = ? AND used_at IS NULL AND expires_at > ? AND lower(target_domain) = ?
|
||||
RETURNING home_user_id
|
||||
`).get(now, rawToken, now, peerDomain) as { home_user_id: string } | undefined;
|
||||
|
||||
if (!claimed) {
|
||||
return sendSigned({ valid: false });
|
||||
}
|
||||
|
||||
// 5. Re-confirm the home user is still native (not tombstoned, not turned
|
||||
// into a replicated stub) since the token was minted.
|
||||
const homeUser = db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.users.id, claimed.home_user_id),
|
||||
eq(schema.users.isDeleted, 0),
|
||||
isNull(schema.users.homeInstance),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!homeUser) {
|
||||
return sendSigned({ valid: false });
|
||||
}
|
||||
|
||||
return sendSigned({ valid: true, homeUserId: homeUser.id, username: homeUser.username });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/users/@me/reattach ────────────────────────────────────────────
|
||||
// Owner-initiated exception to the detach invariant (re-attach spec §3.2).
|
||||
// Requires BOTH identities: the session proves the detached account (local
|
||||
// password authority), the one-time token — verified with the home peer over
|
||||
// signed S2S — proves the new home account. Registered here rather than in
|
||||
// users.ts because it consumes federation-internal machinery (peer HMAC
|
||||
// channel, profile fetch, asset download). URL path stays /api/users/@me/*.
|
||||
app.post<{ Body: { token?: unknown } }>('/api/users/@me/reattach', {
|
||||
preHandler: authenticate,
|
||||
config: { rateLimit: { max: 5, timeWindow: '15 minutes' } },
|
||||
}, async (request, reply) => {
|
||||
const db = getDb();
|
||||
const rawDb = getRawDb();
|
||||
|
||||
const rawToken = (request.body as { token?: unknown } | null)?.token;
|
||||
if (typeof rawToken !== 'string' || !/^[0-9a-f]{64}$/i.test(rawToken)) {
|
||||
return reply.code(400).send({ error: 'token is required (64-char hex)', statusCode: 400 });
|
||||
}
|
||||
|
||||
// Guard 1: session user must be a LIVE detached federated account. A missing
|
||||
// or tombstoned row is a 404 (nothing to re-attach); a live non-detached /
|
||||
// native account is a 403 (re-attach is meaningless — it already syncs).
|
||||
const detached = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (!detached || detached.isDeleted === 1) {
|
||||
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
||||
}
|
||||
if (!detached.homeInstance || detached.federationHomeOrphaned !== 1) {
|
||||
return reply.code(403).send({ error: 'Only detached accounts can re-attach', statusCode: 403 });
|
||||
}
|
||||
|
||||
// Guard 2: the home domain must be an ACTIVE peer — the proof is only as
|
||||
// trustworthy as the S2S channel it is verified over.
|
||||
const homeDomain = extractDomain(detached.homeInstance).toLowerCase();
|
||||
const normPeer = (origin: string) => extractDomain(origin).toLowerCase();
|
||||
const peerRow = db.select().from(schema.federationPeers).all()
|
||||
.find(p => normPeer(p.origin) === homeDomain && p.status === 'active');
|
||||
if (!peerRow) {
|
||||
return reply.code(409).send({ error: 'Home instance is not an active peer', statusCode: 409 });
|
||||
}
|
||||
|
||||
// Guard 3: verify the one-time proof with the home instance (fails closed).
|
||||
const verified = await verifyAttachProofWithPeer(peerRow, rawToken);
|
||||
if (!verified.valid) {
|
||||
return reply.code(401).send({ error: 'Attach proof could not be verified', statusCode: 401 });
|
||||
}
|
||||
|
||||
// Guard 4: if the new identity already has a local row for this domain, it
|
||||
// MUST be a replicated stub (the merge source, §3.3). A real account holding
|
||||
// it means state corruption — abort loudly, do not merge.
|
||||
const normHome = `lower(replace(replace(coalesce(home_instance, ''), 'https://', ''), 'http://', ''))`;
|
||||
const existingRow = rawDb.prepare(`
|
||||
SELECT id, password_hash FROM users
|
||||
WHERE home_user_id = ? AND ${normHome} = ? AND is_deleted = 0 AND id != ?
|
||||
`).get(verified.homeUserId, homeDomain, detached.id) as { id: string; password_hash: string } | undefined;
|
||||
if (existingRow && existingRow.password_hash !== '!federation-replicated') {
|
||||
console.error(`[federation] Re-attach conflict: identity ${verified.homeUserId}@${homeDomain} held by non-stub account ${existingRow.id}`);
|
||||
return reply.code(409).send({ error: 'The new identity is already bound to another account on this instance', statusCode: 409 });
|
||||
}
|
||||
|
||||
// Username: adopt the new home base when it differs (existing collision-suffix
|
||||
// scheme). Usernames are not identity, so a base match keeps the current handle.
|
||||
const currentBase = detached.username.includes('@')
|
||||
? detached.username.slice(0, detached.username.indexOf('@'))
|
||||
: detached.username;
|
||||
let newUsername = detached.username;
|
||||
const newBase = verified.username.toLowerCase();
|
||||
if (newBase !== currentBase.toLowerCase()) {
|
||||
let candidate = `${newBase}@${homeDomain}`;
|
||||
let attempt = 0;
|
||||
while (rawDb.prepare(`SELECT 1 FROM users WHERE username = ? AND id != ?`).get(candidate, detached.id)) {
|
||||
attempt++;
|
||||
candidate = `${newBase}_${attempt}@${homeDomain}`;
|
||||
if (attempt > 10) {
|
||||
candidate = `${newBase}_${randomBytes(4).toString('hex')}@${homeDomain}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
newUsername = candidate;
|
||||
}
|
||||
|
||||
// Merge + re-bind, atomically. All users.id FK repointing lives here; dedupe
|
||||
// rows that would collide on a composite PK / unique index BEFORE repointing
|
||||
// (spec §3.3). The stub row is the only source — a real account holding the
|
||||
// identity was already rejected by guard 4.
|
||||
const dmReconcileResults: DmReconcileResult[] = [];
|
||||
rawDb.transaction(() => {
|
||||
if (existingRow) {
|
||||
const stubId = existingRow.id;
|
||||
const targetId = detached.id;
|
||||
// dm_members (composite PK dm_channel_id+user_id → dedupe): drop the
|
||||
// stub's membership where the detached row is already a member.
|
||||
rawDb.prepare(`DELETE FROM dm_members WHERE user_id = ? AND dm_channel_id IN (SELECT dm_channel_id FROM dm_members WHERE user_id = ?)`).run(stubId, targetId);
|
||||
rawDb.prepare(`UPDATE dm_members SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
|
||||
// dm_messages / messages (RESTRICT FK, no unique on user_id → straight repoint).
|
||||
rawDb.prepare(`UPDATE dm_messages SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
|
||||
rawDb.prepare(`UPDATE messages SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
|
||||
// attachments.uploader_id (plain text column, NO FK, no unique → straight
|
||||
// repoint). A replicated stub that uploaded a DM/channel attachment would
|
||||
// otherwise leave uploader_id dangling at the deleted stub's id — broken
|
||||
// attribution.
|
||||
rawDb.prepare(`UPDATE attachments SET uploader_id = ? WHERE uploader_id = ?`).run(targetId, stubId);
|
||||
// dm_reactions (dedupe on dm_message_id+emoji per user).
|
||||
rawDb.prepare(`DELETE FROM dm_reactions WHERE user_id = ? AND EXISTS (SELECT 1 FROM dm_reactions r2 WHERE r2.user_id = ? AND r2.dm_message_id = dm_reactions.dm_message_id AND r2.emoji = dm_reactions.emoji)`).run(stubId, targetId);
|
||||
rawDb.prepare(`UPDATE dm_reactions SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
|
||||
// reactions (dedupe on message_id+emoji per user).
|
||||
rawDb.prepare(`DELETE FROM reactions WHERE user_id = ? AND EXISTS (SELECT 1 FROM reactions r2 WHERE r2.user_id = ? AND r2.message_id = reactions.message_id AND r2.emoji = reactions.emoji)`).run(stubId, targetId);
|
||||
rawDb.prepare(`UPDATE reactions SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
|
||||
// friends (composite PK user_id+friend_id → dedupe both directions, then
|
||||
// repoint, then drop any self-friendship the repoint created).
|
||||
rawDb.prepare(`DELETE FROM friends WHERE user_id = ? AND friend_id IN (SELECT friend_id FROM friends WHERE user_id = ?)`).run(stubId, targetId);
|
||||
rawDb.prepare(`DELETE FROM friends WHERE friend_id = ? AND user_id IN (SELECT user_id FROM friends WHERE friend_id = ?)`).run(stubId, targetId);
|
||||
rawDb.prepare(`UPDATE friends SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
|
||||
rawDb.prepare(`UPDATE friends SET friend_id = ? WHERE friend_id = ?`).run(targetId, stubId);
|
||||
rawDb.prepare(`DELETE FROM friends WHERE user_id = friend_id`).run();
|
||||
// friend_requests (unique on neither col alone; repoint both, drop self-rows).
|
||||
rawDb.prepare(`UPDATE friend_requests SET from_id = ? WHERE from_id = ?`).run(targetId, stubId);
|
||||
rawDb.prepare(`UPDATE friend_requests SET to_id = ? WHERE to_id = ?`).run(targetId, stubId);
|
||||
rawDb.prepare(`DELETE FROM friend_requests WHERE from_id = to_id`).run();
|
||||
// read_states (composite PK user_id+channel_id → dedupe).
|
||||
rawDb.prepare(`DELETE FROM read_states WHERE user_id = ? AND channel_id IN (SELECT channel_id FROM read_states WHERE user_id = ?)`).run(stubId, targetId);
|
||||
rawDb.prepare(`UPDATE read_states SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
|
||||
// dm_channels.owner_id (plain text column, NO FK → straight repoint).
|
||||
rawDb.prepare(`UPDATE dm_channels SET owner_id = ? WHERE owner_id = ?`).run(targetId, stubId);
|
||||
rawDb.prepare(`DELETE FROM users WHERE id = ?`).run(stubId);
|
||||
}
|
||||
|
||||
// Group-DM ownership continuity: channels the OLD identity owned keep
|
||||
// authority under the NEW identity (owner_home_user_id is the S2S
|
||||
// authority key, not a users.id FK).
|
||||
const normOwnerHome = `lower(replace(replace(coalesce(owner_home_instance, ''), 'https://', ''), 'http://', ''))`;
|
||||
rawDb.prepare(`UPDATE dm_channels SET owner_home_user_id = ? WHERE owner_home_user_id = ? AND ${normOwnerHome} = ?`)
|
||||
.run(verified.homeUserId, detached.homeUserId, homeDomain);
|
||||
|
||||
// Re-bind. profile_updated_at is nulled so the home's next profile_update
|
||||
// (any version) tier-1 matches and applies (the accept-and-skip guards
|
||||
// only fire on federation_home_orphaned = 1).
|
||||
rawDb.prepare(`UPDATE users SET home_user_id = ?, federation_home_orphaned = 0, username = ?, profile_updated_at = NULL WHERE id = ?`)
|
||||
.run(verified.homeUserId, newUsername, detached.id);
|
||||
|
||||
// Reconcile the account's 1-on-1 DM channels: the home_user_id just
|
||||
// changed, so every 1-on-1 federatedId derived from it is now stale.
|
||||
// Re-key or merge each into its new-identity channel so history stays a
|
||||
// single conversation (reattach-dm-reconcile spec §3.2). Group DMs (UUID
|
||||
// federatedId / != 2 members) are skipped by the helper.
|
||||
const oneOnOne = rawDb.prepare(`
|
||||
SELECT c.id FROM dm_channels c
|
||||
WHERE c.deleted_at IS NULL
|
||||
AND c.federated_id IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM dm_members m WHERE m.dm_channel_id = c.id AND m.user_id = ?)
|
||||
AND (SELECT count(*) FROM dm_members m2 WHERE m2.dm_channel_id = c.id) = 2
|
||||
`).all(detached.id) as Array<{ id: string }>;
|
||||
for (const c of oneOnOne) {
|
||||
// A merge earlier in this loop may have deleted this id — reconcile
|
||||
// returns noop for a missing/mutated channel, so the loop is convergent.
|
||||
const result = reconcileDmChannelFederatedId(rawDb, c.id);
|
||||
if (result.action !== 'noop') dmReconcileResults.push(result);
|
||||
}
|
||||
})();
|
||||
|
||||
// Best-effort initial profile pull (spec §3.2 step 4). Failure is fine — the
|
||||
// account is re-attached; the next relay fills the profile.
|
||||
const home = await fetchHomeProfileByHomeId(peerRow, verified.homeUserId);
|
||||
if (home) {
|
||||
let avatar: string | null = null;
|
||||
let banner: string | null = null;
|
||||
if (home.profile.avatar) {
|
||||
const url = home.profile.avatar.startsWith('http') ? home.profile.avatar : `${peerRow.origin}/api/uploads/${home.profile.avatar}`;
|
||||
avatar = (await downloadProfileAsset(url, peerRow.origin)) ?? url;
|
||||
}
|
||||
if (home.profile.banner) {
|
||||
const url = home.profile.banner.startsWith('http') ? home.profile.banner : `${peerRow.origin}/api/uploads/${home.profile.banner}`;
|
||||
banner = (await downloadProfileAsset(url, peerRow.origin)) ?? url;
|
||||
}
|
||||
db.update(schema.users).set({
|
||||
displayName: home.profile.displayName ?? home.username,
|
||||
avatar,
|
||||
banner,
|
||||
avatarColor: home.profile.avatarColor ?? detached.avatarColor,
|
||||
bio: home.profile.bio,
|
||||
}).where(eq(schema.users.id, detached.id)).run();
|
||||
}
|
||||
|
||||
const updated = db.select().from(schema.users).where(eq(schema.users.id, detached.id)).get()!;
|
||||
console.log(`[federation] Re-attached account ${updated.id} (${updated.username}): ${detached.homeUserId} → ${verified.homeUserId} @ ${homeDomain}`);
|
||||
|
||||
// Broadcast to friends / DM / space co-members + all self connections.
|
||||
const targetIds = collectProfileBroadcastTargetIds(updated.id);
|
||||
targetIds.add(updated.id);
|
||||
for (const uid of targetIds) {
|
||||
connectionManager.sendToUser(uid, { type: 'user_updated' as const, user: sanitizeUser(updated, uid === updated.id) });
|
||||
}
|
||||
|
||||
// Push DM-list refresh for reconciled channels to affected local members so
|
||||
// the merged/re-keyed conversation replaces the split without a reload
|
||||
// (reattach-dm-reconcile spec §3.4). Reuses existing events, no new type:
|
||||
// - merged: dm_channel_closed removes the stale source entry; dm_channel_created
|
||||
// (full DmChannel payload — the client handler reads dmChannel.members) resurfaces
|
||||
// the surviving target with its merged history.
|
||||
// - rekeyed: dm_channel_created upserts the channel by id (spaceStore.addDmChannel
|
||||
// replaces by id), refreshing the now-stale federatedId in place. dm_channel_updated
|
||||
// would only patch name/icon, not federatedId, so it cannot heal the client here.
|
||||
for (const r of dmReconcileResults) {
|
||||
const targetPayload = buildDmChannelPayload(r.targetChannelId, db);
|
||||
for (const uid of r.affectedUserIds) {
|
||||
if (r.action === 'merged') {
|
||||
connectionManager.sendToUser(uid, { type: 'dm_channel_closed' as const, dmChannelId: r.channelId });
|
||||
}
|
||||
if (targetPayload) {
|
||||
connectionManager.sendToUser(uid, { type: 'dm_channel_created' as const, dmChannel: targetPayload });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reply.code(200).send({ success: true, user: sanitizeUser(updated, true) });
|
||||
});
|
||||
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { getDb, schema } from '../../../db/index.js';
|
||||
import { parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js';
|
||||
import { and, eq, isNull, or } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { isLookupRateLimited, isNonceDuplicate } from '../rateLimits.js';
|
||||
|
||||
export function registerLookupRoutes(app: FastifyInstance): void {
|
||||
// ─── POST /api/federation/users/lookup ─────────────────────────────────────
|
||||
// Server-to-server: resolve a username on this instance to its canonical
|
||||
// (homeUserId, profile snapshot). Used by another instance to construct a
|
||||
// friend_request_create event without requiring a federated user account.
|
||||
//
|
||||
// Returns 200 with profile snapshot for native users (regardless of the
|
||||
// user's `discoverable` setting — exact-handle resolution).
|
||||
// Returns 404 for tombstoned users, replicated stubs, or unknown usernames.
|
||||
app.post<{ Body: { username?: unknown } }>(
|
||||
'/api/federation/users/lookup',
|
||||
{ bodyLimit: 4 * 1024 },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
// 1. Verify HMAC (mirror relay endpoint)
|
||||
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 });
|
||||
}
|
||||
|
||||
// 1b. Nonce-based 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 rawUsername = (request.body as { username?: unknown } | null)?.username;
|
||||
if (typeof rawUsername !== 'string') {
|
||||
return reply.code(400).send({ error: 'username is required (string)', statusCode: 400 });
|
||||
}
|
||||
const username = rawUsername.trim().toLowerCase();
|
||||
if (!username) {
|
||||
return reply.code(400).send({ error: 'username is required', statusCode: 400 });
|
||||
}
|
||||
|
||||
// 3. Native-only lookup with isDeleted filter; discoverable is NOT consulted.
|
||||
const user = db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.users.username, username),
|
||||
eq(schema.users.isDeleted, 0),
|
||||
isNull(schema.users.homeInstance),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!user) {
|
||||
return reply.code(404).send({ found: false, code: 'user_not_found' });
|
||||
}
|
||||
|
||||
return reply.code(200).send({
|
||||
found: true,
|
||||
user: {
|
||||
homeUserId: user.id,
|
||||
username: user.username,
|
||||
profile: {
|
||||
displayName: user.displayName,
|
||||
avatar: user.avatar,
|
||||
avatarColor: user.avatarColor,
|
||||
banner: user.banner,
|
||||
bio: user.bio,
|
||||
status: user.status as 'online' | 'idle' | 'dnd' | 'offline' | null,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ─── 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,
|
||||
status: user.status as 'online' | 'idle' | 'dnd' | 'offline' | null,
|
||||
banner: user.banner,
|
||||
bio: user.bio,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import path from 'node:path';
|
||||
import { getDb, schema } from '../../../db/index.js';
|
||||
import { authenticate, requireAdmin } from '../../../utils/auth.js';
|
||||
import { buildFederationHeaders, generateHmacSecret } from '../../../utils/federationAuth.js';
|
||||
import { onPeerDeactivated } from '../../../utils/federationPeerActivation.js';
|
||||
import { probePeerReachable, recoverOrDetectReset } from '../../../utils/federationRecovery.js';
|
||||
import { homeInstanceMatch } from '../../../utils/federationReset.js';
|
||||
import { connectionManager } from '../../../ws/handler.js';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { resolveLocalOrigin, sanitizePeer } from '../origin.js';
|
||||
|
||||
export function registerPeerAdminRoutes(app: FastifyInstance): void {
|
||||
// ─── GET /api/federation/peers ─────────────────────────────────────────────
|
||||
// Admin-only: list all federation peers (hmacSecret excluded).
|
||||
app.get(
|
||||
'/api/federation/peers',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (_request, reply) => {
|
||||
const db = getDb();
|
||||
const peers = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.all();
|
||||
|
||||
return reply.code(200).send({ peers: peers.map(sanitizePeer) });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── GET /api/federation/reset-events ──────────────────────────────────────
|
||||
// Admin-only: the durable reset journal + per-origin orphaned real accounts,
|
||||
// for the "Reset cleanup" admin surface (instance-epoch self-healing §6.4).
|
||||
// Read-only; disposition actions reuse the existing one-click Re-peer (reset
|
||||
// + initiate) and the existing DELETE /api/admin/users/:id (full-purge Remove).
|
||||
app.get(
|
||||
'/api/federation/reset-events',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (_request, reply) => {
|
||||
const db = getDb();
|
||||
const events = db.select().from(schema.federationResetEvents).all();
|
||||
|
||||
const result = events.map((ev) => {
|
||||
const accounts = db
|
||||
.select({
|
||||
id: schema.users.id,
|
||||
username: schema.users.username,
|
||||
displayName: schema.users.displayName,
|
||||
avatarColor: schema.users.avatarColor,
|
||||
})
|
||||
.from(schema.users)
|
||||
.where(and(
|
||||
eq(schema.users.federationHomeOrphaned, 1),
|
||||
eq(schema.users.isDeleted, 0),
|
||||
homeInstanceMatch(ev.origin),
|
||||
))
|
||||
.all();
|
||||
|
||||
const orphanedAccounts = accounts.map((a) => {
|
||||
const ownedSpaces = db
|
||||
.select({ id: schema.spaces.id, name: schema.spaces.name })
|
||||
.from(schema.spaces)
|
||||
.where(eq(schema.spaces.ownerId, a.id))
|
||||
.all();
|
||||
const spaceMemberCount = db
|
||||
.select({ n: sql<number>`count(*)` })
|
||||
.from(schema.spaceMembers)
|
||||
.where(eq(schema.spaceMembers.userId, a.id))
|
||||
.get()?.n ?? 0;
|
||||
const messageCount = db
|
||||
.select({ n: sql<number>`count(*)` })
|
||||
.from(schema.messages)
|
||||
.where(eq(schema.messages.userId, a.id))
|
||||
.get()?.n ?? 0;
|
||||
return { ...a, ownedSpaces, spaceMemberCount, messageCount };
|
||||
});
|
||||
|
||||
return {
|
||||
origin: ev.origin,
|
||||
deadEpoch: ev.deadEpoch,
|
||||
newEpoch: ev.newEpoch,
|
||||
detectedAt: ev.detectedAt,
|
||||
resolvedAt: ev.resolvedAt,
|
||||
stubCount: ev.stubCount,
|
||||
orphanedAccountCount: ev.orphanedAccountCount,
|
||||
acknowledgedAt: ev.acknowledgedAt,
|
||||
orphanedAccounts,
|
||||
};
|
||||
});
|
||||
|
||||
return reply.code(200).send({ events: result });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/reset-events/acknowledge ─────────────────────────
|
||||
// Admin-only: dismiss a reset event from the admin banner. Purely
|
||||
// informational state — detached accounts stay detached and functional;
|
||||
// acknowledging just stops the surface from re-listing them (detach spec §4.6).
|
||||
app.post<{ Body: { origin: string } }>(
|
||||
'/api/federation/reset-events/acknowledge',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (request, reply) => {
|
||||
const { origin } = request.body;
|
||||
if (!origin || typeof origin !== 'string') {
|
||||
return reply.code(400).send({ error: 'origin is required', statusCode: 400 });
|
||||
}
|
||||
const db = getDb();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(schema.federationResetEvents)
|
||||
.where(eq(schema.federationResetEvents.origin, origin))
|
||||
.get();
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ error: 'No reset event for this origin', statusCode: 404 });
|
||||
}
|
||||
if (existing.acknowledgedAt === null) {
|
||||
db.update(schema.federationResetEvents)
|
||||
.set({ acknowledgedAt: Date.now() })
|
||||
.where(eq(schema.federationResetEvents.origin, origin))
|
||||
.run();
|
||||
}
|
||||
return reply.code(200).send({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── DELETE /api/federation/peers/:id ──────────────────────────────────────
|
||||
// Admin-only: revoke a federation peer and clean up its outbox.
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/federation/peers/:id',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, id))
|
||||
.get();
|
||||
|
||||
if (!peer) {
|
||||
return reply.code(404).send({ error: 'Peer not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
// Revoke the peer
|
||||
db.update(schema.federationPeers)
|
||||
.set({ status: 'revoked' })
|
||||
.where(eq(schema.federationPeers.id, id))
|
||||
.run();
|
||||
|
||||
onPeerDeactivated(id, 'admin_revoked').catch(err =>
|
||||
console.error('[federation] onPeerDeactivated from admin revoke failed:', err),
|
||||
);
|
||||
|
||||
// Delete all outbox entries for this peer
|
||||
db.delete(schema.federationOutbox)
|
||||
.where(eq(schema.federationOutbox.peerId, id))
|
||||
.run();
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/peers/:id/reset ──────────────────────────────────
|
||||
// Admin-only: reset a peer that has transitioned to needs_attention.
|
||||
// Deletes the local peer row (cascade-deletes outbox entries via FK).
|
||||
// Admin must re-initiate peering out of band after reset.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/federation/peers/:id/reset',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, id))
|
||||
.get();
|
||||
|
||||
if (!peer) {
|
||||
return reply.code(404).send({ error: 'Peer not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (peer.status !== 'needs_attention') {
|
||||
return reply.code(400).send({
|
||||
error: 'Reset is only available for peers in the needs_attention state. Use revoke for active peers.',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Cascade-delete handles federation_outbox entries (FK onDelete: 'cascade').
|
||||
db.delete(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, id))
|
||||
.run();
|
||||
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/peers/:id/recheck ────────────────────────────────
|
||||
// Admin-only: run an immediate reachability probe on an unreachable peer.
|
||||
// On success the peer transitions to active (outbox flushes on the next tick).
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/federation/peers/:id/recheck',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, id))
|
||||
.get();
|
||||
|
||||
if (!peer) {
|
||||
return reply.code(404).send({ error: 'Peer not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (peer.status !== 'unreachable') {
|
||||
return reply.code(400).send({
|
||||
error: 'Recheck is only available for unreachable peers.',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const probe = await probePeerReachable(peer.origin);
|
||||
|
||||
if (probe.reachable) {
|
||||
const outcome = await recoverOrDetectReset(peer, probe);
|
||||
if (outcome === 'reset_detected') {
|
||||
// The peer is a new incarnation on the same domain. It was routed to
|
||||
// needs_attention (detection-only, no rekey) and must NOT be recovered
|
||||
// to active until an admin re-peers through the authenticated path.
|
||||
return reply.code(200).send({ recovered: false, status: 'needs_attention' });
|
||||
}
|
||||
return reply.code(200).send({ recovered: true, status: 'active' });
|
||||
}
|
||||
|
||||
// Probe failed — advance pacing so a manual attempt stays consistent with
|
||||
// the recovery worker's schedule.
|
||||
db.update(schema.federationPeers)
|
||||
.set({ probeAttempts: peer.probeAttempts + 1, lastProbeAt: Date.now() })
|
||||
.where(eq(schema.federationPeers.id, peer.id))
|
||||
.run();
|
||||
|
||||
return reply.code(200).send({ recovered: false, status: 'unreachable' });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── PATCH /api/federation/peers/:id ────────────────────────────────────────
|
||||
// Admin-only: update peer settings (e.g. auto-rotation interval).
|
||||
app.patch<{ Params: { id: string }; Body: { autoRotateIntervalDays?: number } }>(
|
||||
'/api/federation/peers/:id',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, id))
|
||||
.get();
|
||||
|
||||
if (!peer) {
|
||||
return reply.code(404).send({ error: 'Peer not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const updateData: Record<string, number> = {};
|
||||
|
||||
if (request.body.autoRotateIntervalDays !== undefined) {
|
||||
const interval = Number(request.body.autoRotateIntervalDays);
|
||||
if (isNaN(interval) || !Number.isInteger(interval) || interval < 1 || interval > 365) {
|
||||
return reply.code(400).send({ error: 'autoRotateIntervalDays must be an integer between 1 and 365', statusCode: 400 });
|
||||
}
|
||||
updateData.autoRotateIntervalDays = interval;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return reply.code(400).send({ error: 'No valid fields to update', statusCode: 400 });
|
||||
}
|
||||
|
||||
db.update(schema.federationPeers)
|
||||
.set(updateData)
|
||||
.where(eq(schema.federationPeers.id, id))
|
||||
.run();
|
||||
|
||||
const updated = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, id))
|
||||
.get();
|
||||
|
||||
return reply.code(200).send({ peer: sanitizePeer(updated!) });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── DELETE /api/federation/peers/:id/permanent ─────────────────────────────
|
||||
// Admin-only: permanently delete a revoked peer record.
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/federation/peers/:id/permanent',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, id))
|
||||
.get();
|
||||
|
||||
if (!peer) {
|
||||
return reply.code(404).send({ error: 'Peer not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (peer.status !== 'revoked') {
|
||||
return reply.code(400).send({
|
||||
error: 'Only revoked peers can be permanently deleted. Revoke the peer first.',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
db.delete(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, id))
|
||||
.run();
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/peers/:id/rotate ──────────────────────────────────
|
||||
// Admin-only: trigger immediate secret rotation for a peer.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/federation/peers/:id/rotate',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, id))
|
||||
.get();
|
||||
|
||||
if (!peer) {
|
||||
return reply.code(404).send({ error: 'Peer not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (peer.status !== 'active') {
|
||||
return reply.code(400).send({ error: 'Can only rotate secrets for active peers', statusCode: 400 });
|
||||
}
|
||||
|
||||
if (peer.pendingHmacSecret) {
|
||||
return reply.code(409).send({
|
||||
error: 'A secret rotation is already in progress — wait for it to complete',
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
|
||||
const newSecret = generateHmacSecret();
|
||||
let localOrigin: string;
|
||||
try {
|
||||
localOrigin = resolveLocalOrigin();
|
||||
} catch {
|
||||
return reply.code(500).send({
|
||||
error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.',
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
// Send rotation request to peer, signed with the CURRENT (old) secret
|
||||
try {
|
||||
const rotateBody = JSON.stringify({ newSecret });
|
||||
const headers = buildFederationHeaders(rotateBody, peer.hmacSecret, localOrigin);
|
||||
|
||||
const response = await fetch(`${peer.origin}/api/federation/peer/rotate`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: rotateBody,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Remote instance rejected rotation (HTTP ${response.status})`;
|
||||
try {
|
||||
const body = await response.json() as { error?: string };
|
||||
if (body.error) errorMessage = body.error;
|
||||
} catch { /* ignore parse failures */ }
|
||||
|
||||
return reply.code(502).send({ error: errorMessage, statusCode: 502 });
|
||||
}
|
||||
|
||||
// Store pending secret locally AFTER remote peer confirms acceptance
|
||||
db.update(schema.federationPeers)
|
||||
.set({
|
||||
pendingHmacSecret: newSecret,
|
||||
secretRotationAt: Date.now(),
|
||||
})
|
||||
.where(eq(schema.federationPeers.id, peer.id))
|
||||
.run();
|
||||
|
||||
console.log(`[federation] Secret rotation initiated with peer ${peer.origin}`);
|
||||
|
||||
return reply.code(200).send({ success: true, gracePeriodMs: 900_000 });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||
return reply.code(504).send({
|
||||
error: 'Remote instance did not respond within 10 seconds',
|
||||
statusCode: 504,
|
||||
});
|
||||
}
|
||||
return reply.code(502).send({
|
||||
error: `Failed to reach remote instance: ${message}`,
|
||||
statusCode: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
import path from 'node:path';
|
||||
import { getDb, schema } from '../../../db/index.js';
|
||||
import { authenticate, requireAdmin } from '../../../utils/auth.js';
|
||||
import { generateHmacSecret, parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js';
|
||||
import { fetchPeerEpoch, getInstanceId } from '../../../utils/federationEpoch.js';
|
||||
import { onPeerActivated } from '../../../utils/federationPeerActivation.js';
|
||||
import { markPeerReset } from '../../../utils/federationReset.js';
|
||||
import { generateSnowflake } from '../../../utils/snowflake.js';
|
||||
import { connectionManager } from '../../../ws/handler.js';
|
||||
import { and, eq, inArray, or } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { queueApprovalRequest } from './approvals.js';
|
||||
import { resolveLocalOrigin, sanitizePeer, validateOrigin } from '../origin.js';
|
||||
import { isAcceptRateLimited, isEnsureRateLimited } from '../rateLimits.js';
|
||||
|
||||
export function registerPeerHandshakeRoutes(app: FastifyInstance): void {
|
||||
// ─── POST /api/federation/peer/initiate ────────────────────────────────────
|
||||
// Admin-only: start a peering handshake with a remote instance.
|
||||
app.post<{ Body: { remoteOrigin: string } }>(
|
||||
'/api/federation/peer/initiate',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (request, reply) => {
|
||||
const { remoteOrigin: rawOrigin } = request.body ?? {};
|
||||
if (!rawOrigin || typeof rawOrigin !== 'string') {
|
||||
return reply.code(400).send({ error: 'remoteOrigin is required', statusCode: 400 });
|
||||
}
|
||||
|
||||
const remoteOrigin = validateOrigin(rawOrigin);
|
||||
if (!remoteOrigin) {
|
||||
return reply.code(400).send({ error: 'remoteOrigin must be a valid HTTPS URL (HTTP is only allowed for localhost)', statusCode: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Check if a peer already exists for this origin
|
||||
const existing = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.origin, remoteOrigin))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === 'active') {
|
||||
return reply.code(200).send({ peer: sanitizePeer(existing) });
|
||||
}
|
||||
if (existing.status === 'pending') {
|
||||
return reply.code(409).send({
|
||||
error: 'A peering handshake with this instance is already in progress',
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
if (existing.status === 'awaiting_approval') {
|
||||
return reply.code(409).send({
|
||||
error: "A peering handshake with this instance is awaiting the remote admin's approval",
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
// Every remaining terminal/parked state is safe to clear and re-initiate
|
||||
// from — falling through here (rather than to the db.insert below) is what
|
||||
// keeps this route from violating UNIQUE(origin) and 500-ing.
|
||||
// - revoked: local admin revoked; re-initiate cleanly.
|
||||
// - rejected: a prior attempt was rejected; allow the local admin's
|
||||
// authenticated retry (mirrors revoked).
|
||||
// - needs_attention: this IS the one-click Re-peer step (resetPeer +
|
||||
// initiate). Deleting the row here is equivalent to the documented
|
||||
// reset: the reset-heal snapshot lives on users.federation_heal_pending
|
||||
// (not the peer row) and the federation_reset_events journal is designed
|
||||
// to survive peer-row deletion (design §4.2/§6.1), and onPeerActivated
|
||||
// after the fresh handshake re-triggers the heal — so no recovery state
|
||||
// is lost by removing the local needs_attention peer row here.
|
||||
if (
|
||||
existing.status === 'revoked' ||
|
||||
existing.status === 'rejected' ||
|
||||
existing.status === 'needs_attention'
|
||||
) {
|
||||
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, existing.id)).run();
|
||||
}
|
||||
}
|
||||
|
||||
let localOrigin: string;
|
||||
try {
|
||||
localOrigin = resolveLocalOrigin();
|
||||
} catch {
|
||||
return reply.code(500).send({
|
||||
error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.',
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent self-peering
|
||||
if (localOrigin === remoteOrigin) {
|
||||
return reply.code(400).send({ error: 'Cannot peer with yourself', statusCode: 400 });
|
||||
}
|
||||
|
||||
const hmacSecret = generateHmacSecret();
|
||||
const peerId = generateSnowflake();
|
||||
const now = Date.now();
|
||||
|
||||
// Store peer as pending
|
||||
db.insert(schema.federationPeers).values({
|
||||
id: peerId,
|
||||
origin: remoteOrigin,
|
||||
hmacSecret,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
// Initiate the server-to-server handshake
|
||||
try {
|
||||
const response = await fetch(`${remoteOrigin}/api/federation/peer/accept`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceOrigin: localOrigin,
|
||||
hmacSecret,
|
||||
instanceName: db
|
||||
.select({ name: schema.instanceSettings.instanceName })
|
||||
.from(schema.instanceSettings)
|
||||
.where(eq(schema.instanceSettings.id, 1))
|
||||
.get()?.name ?? undefined,
|
||||
instanceId: getInstanceId(),
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (response.status === 202) {
|
||||
// Remote instance queued our request for admin approval
|
||||
// (autoAcceptPeering is off on their side). Do NOT activate the
|
||||
// local peer — mirror the auto-peer flow in federationPeering.ts
|
||||
// by transitioning the pending record to awaiting_approval.
|
||||
// Capture the approval token they returned so the next inbound
|
||||
// /peer/accept (when their admin approves) can be verified. §3.7.
|
||||
let returnedToken: string | null = null;
|
||||
try {
|
||||
const body = (await response.json()) as { approvalToken?: string };
|
||||
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
|
||||
returnedToken = body.approvalToken;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON / empty body — legacy peer.
|
||||
}
|
||||
|
||||
db.update(schema.federationPeers)
|
||||
.set({ status: 'awaiting_approval', approvalToken: returnedToken })
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.run();
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.get();
|
||||
|
||||
if (!peer) {
|
||||
return reply.code(500).send({ error: 'Failed to read peer after queuing', statusCode: 500 });
|
||||
}
|
||||
|
||||
return reply.code(202).send({ peer: sanitizePeer(peer) });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Read the body exactly ONCE here — response.json()/text() consumes the
|
||||
// stream, so both the honest-409 branch and the generic branch below
|
||||
// share this single parse (no double-read of the same Response).
|
||||
const rawBody = await response.text().catch(() => '');
|
||||
let parsed: { error?: string; code?: string } = {};
|
||||
try { parsed = JSON.parse(rawBody) as { error?: string; code?: string }; } catch { /* non-JSON body */ }
|
||||
|
||||
// Responder honestly refused: it already holds peering for us and will
|
||||
// not rekey (anti-hijack). Do NOT create a conflicting row — delete the
|
||||
// pending row so our slot stays clean and the remote's own later Re-peer
|
||||
// can land on a fresh responder slot. Surface an actionable reason.
|
||||
if (response.status === 409 && parsed.code === 'PEER_EXISTS_RESET_REQUIRED') {
|
||||
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).run();
|
||||
return reply.code(409).send({
|
||||
error: 'The remote instance still holds stale peering for you. Ask its admin to reset (or Re-peer) their side, then try again.',
|
||||
code: 'PEER_EXISTS_RESET_REQUIRED',
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
|
||||
const errorMessage = parsed.error || `Remote instance rejected peering (HTTP ${response.status})`;
|
||||
// Clean up the pending peer
|
||||
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).run();
|
||||
return reply.code(502).send({ error: errorMessage, statusCode: 502 });
|
||||
}
|
||||
|
||||
// Remote accepted — activate the peer. Parse the remote's instanceName
|
||||
// and instanceId (epoch) from the response body so the federation panel
|
||||
// renders a friendly label and we record the peer's authenticated
|
||||
// epoch baseline. Tolerate omission and non-JSON bodies.
|
||||
let remoteInstanceName: string | null = null;
|
||||
let remoteInstanceId: string | null = null;
|
||||
try {
|
||||
const body = (await response.json()) as { instanceName?: string | null; instanceId?: string | null };
|
||||
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
|
||||
remoteInstanceName = body.instanceName;
|
||||
}
|
||||
if (typeof body?.instanceId === 'string' && body.instanceId.length > 0) {
|
||||
remoteInstanceId = body.instanceId;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON body — leave null.
|
||||
}
|
||||
|
||||
// The responder returned 200 → it claims it adopted our secret. PROVE it
|
||||
// with a signed round-trip before trusting the peering (catches BUG-1: a
|
||||
// responder that reported success without adopting, and any residual
|
||||
// desync). fetchPeerEpoch signs with the just-negotiated secret; a desync
|
||||
// → 401/403 → null. Park the peer in needs_attention instead of falsely
|
||||
// activating so the admin sees "re-peer incomplete", not a dead-active row.
|
||||
const verifiedEpoch = await fetchPeerEpoch({ origin: remoteOrigin, hmacSecret });
|
||||
if (!verifiedEpoch) {
|
||||
db.update(schema.federationPeers)
|
||||
.set({ status: 'needs_attention', needsAttentionReason: 'repeer_incomplete', lastSeenAt: Date.now() })
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.run();
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
const parked = db.select().from(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).get();
|
||||
return reply.code(200).send({ peer: parked ? sanitizePeer(parked) : null, verified: false });
|
||||
}
|
||||
|
||||
db.update(schema.federationPeers)
|
||||
// The baseline is trust-consequential (design §9 — a poisoned baseline can drive
|
||||
// a spurious heal), so store the epoch we cryptographically verified via the signed
|
||||
// /epoch round-trip, not the unverified handshake-response body. They are normally
|
||||
// identical; the verified one is authoritative if they ever differ.
|
||||
.set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName, peerInstanceId: verifiedEpoch, needsAttentionReason: null, approvalToken: null })
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.run();
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
onPeerActivated(peerId, 'initiate_accepted').catch(err =>
|
||||
console.error('[federation] onPeerActivated from /peer/initiate failed:', err)
|
||||
);
|
||||
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.id, peerId))
|
||||
.get();
|
||||
|
||||
if (!peer) {
|
||||
return reply.code(500).send({ error: 'Failed to read peer after activation', statusCode: 500 });
|
||||
}
|
||||
|
||||
return reply.code(200).send({ peer: sanitizePeer(peer), verified: true });
|
||||
} catch (err: unknown) {
|
||||
// Clean up the pending peer on network/timeout errors
|
||||
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).run();
|
||||
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||
return reply.code(504).send({
|
||||
error: 'Remote instance did not respond within 10 seconds',
|
||||
statusCode: 504,
|
||||
});
|
||||
}
|
||||
return reply.code(502).send({
|
||||
error: `Failed to reach remote instance: ${message}`,
|
||||
statusCode: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/peer/accept ──────────────────────────────────────
|
||||
// Server-to-server: accept a peering request from a remote instance.
|
||||
// No JWT auth — this is first contact. Rate-limited by IP.
|
||||
app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string; instanceId?: string; approvalToken?: string } }>(
|
||||
'/api/federation/peer/accept',
|
||||
async (request, reply) => {
|
||||
const clientIp = request.ip;
|
||||
if (isAcceptRateLimited(clientIp)) {
|
||||
return reply.code(429).send({
|
||||
error: 'Too many peering requests — try again later',
|
||||
statusCode: 429,
|
||||
});
|
||||
}
|
||||
|
||||
const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName, instanceId: reqInstanceId, approvalToken: inboundToken } = request.body ?? {};
|
||||
|
||||
if (!rawOrigin || typeof rawOrigin !== 'string') {
|
||||
return reply.code(400).send({ error: 'sourceOrigin is required', statusCode: 400 });
|
||||
}
|
||||
if (!hmacSecret || typeof hmacSecret !== 'string') {
|
||||
return reply.code(400).send({ error: 'hmacSecret is required', statusCode: 400 });
|
||||
}
|
||||
|
||||
const sourceOrigin = validateOrigin(rawOrigin);
|
||||
if (!sourceOrigin) {
|
||||
return reply.code(400).send({ error: 'sourceOrigin must be a valid HTTPS URL (HTTP is only allowed for localhost)', statusCode: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const settings = db
|
||||
.select({
|
||||
instanceName: schema.instanceSettings.instanceName,
|
||||
autoAcceptPeering: schema.instanceSettings.autoAcceptPeering,
|
||||
})
|
||||
.from(schema.instanceSettings)
|
||||
.where(eq(schema.instanceSettings.id, 1))
|
||||
.get();
|
||||
|
||||
const ourInstanceName = settings?.instanceName ?? null;
|
||||
const ourInstanceId = getInstanceId();
|
||||
const autoAccept = settings?.autoAcceptPeering ?? 1;
|
||||
|
||||
// ── autoAcceptPeering gate ──────────────────────────────────────────
|
||||
// When auto-accept is disabled, only allow incoming accept requests
|
||||
// that correspond to a local pending peer (i.e., a local admin
|
||||
// initiated the handshake). Unsolicited requests are rejected.
|
||||
|
||||
if (autoAccept === 0) {
|
||||
// Check if the local admin already initiated or approved peering with this origin.
|
||||
// 'pending' = admin used peer/initiate (handshake in progress)
|
||||
// 'awaiting_approval' = admin approved an earlier request, handshake was sent,
|
||||
// remote queued it (202). Now the remote admin approved too and is handshaking
|
||||
// back to us. We should accept — both admins have approved.
|
||||
const localPending = db
|
||||
.select({ id: schema.federationPeers.id })
|
||||
.from(schema.federationPeers)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.federationPeers.origin, sourceOrigin),
|
||||
inArray(schema.federationPeers.status, ['pending', 'awaiting_approval']),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!localPending) {
|
||||
// Check if this origin is blocked (previously denied)
|
||||
const blockedPeer = db
|
||||
.select({ id: schema.federationPeers.id })
|
||||
.from(schema.federationPeers)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.federationPeers.origin, sourceOrigin),
|
||||
eq(schema.federationPeers.status, 'rejected'),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (blockedPeer) {
|
||||
return reply.code(403).send({
|
||||
error: 'This instance requires manual peering approval',
|
||||
code: 'PEERING_REQUIRES_APPROVAL',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if peer already exists
|
||||
const existing = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.origin, sourceOrigin))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === 'active' || existing.status === 'needs_attention') {
|
||||
// Idempotent — already peered (or peering is in needs_attention state).
|
||||
// In both cases we refuse to overwrite hmac_secret via this
|
||||
// unauthenticated endpoint. An unauthenticated caller cannot
|
||||
// prove prior trust, and needs_attention means "we don't know
|
||||
// why this broke" — letting an unauthenticated request flip it
|
||||
// to active with a new secret defeats the purpose.
|
||||
//
|
||||
// Legitimate recovery path: local admin clicks "Reset peering" →
|
||||
// row is deleted → remote's /peer/accept then lands on a
|
||||
// non-existent row and the normal handshake path runs.
|
||||
//
|
||||
// Detection-only: if the inbound epoch differs from our trusted
|
||||
// baseline, the peer is a NEW incarnation on the same domain (a
|
||||
// wipe-and-reinstall). Route it to needs_attention + snapshot +
|
||||
// journal — but STILL return 409 (PEER_EXISTS_RESET_REQUIRED) and
|
||||
// STILL do not rekey. The anti-hijack guard above is preserved
|
||||
// verbatim; detection never grants capability.
|
||||
if (reqInstanceId && existing.peerInstanceId && reqInstanceId !== existing.peerInstanceId) {
|
||||
markPeerReset(existing.id, sourceOrigin, existing.peerInstanceId, reqInstanceId);
|
||||
}
|
||||
// Anti-hijack: we did NOT adopt the caller's secret. Report that
|
||||
// honestly (409) instead of a false success (was 200 {accepted:true}),
|
||||
// so the initiator does not false-activate into a permanent HMAC
|
||||
// desync. Legacy initiators read only response.ok → they fail loudly
|
||||
// (never a silent desync); new initiators special-case this code.
|
||||
return reply.code(409).send({
|
||||
accepted: false,
|
||||
code: 'PEER_EXISTS_RESET_REQUIRED',
|
||||
error: 'This instance already holds peering for you; its admin must reset that peering before a new handshake can be accepted.',
|
||||
instanceName: ourInstanceName,
|
||||
instanceId: ourInstanceId,
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
if (existing.status === 'revoked') {
|
||||
return reply.code(403).send({
|
||||
error: 'Peering with this instance has been revoked',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
if (existing.status === 'rejected') {
|
||||
// A remote admin manually initiated peering with us after we
|
||||
// previously auto-rejected them. Override rejected → active.
|
||||
db.update(schema.federationPeers)
|
||||
.set({
|
||||
hmacSecret,
|
||||
instanceName: reqInstanceName ?? null,
|
||||
peerInstanceId: reqInstanceId ?? null,
|
||||
status: 'active',
|
||||
lastSeenAt: Date.now(),
|
||||
})
|
||||
.where(eq(schema.federationPeers.id, existing.id))
|
||||
.run();
|
||||
|
||||
// Broadcast activation to all connected local users
|
||||
for (const uid of connectionManager.getAllOnlineUserIds()) {
|
||||
connectionManager.sendToUser(uid, {
|
||||
type: 'federation_peer_active' as const,
|
||||
peerOrigin: sourceOrigin,
|
||||
});
|
||||
}
|
||||
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
onPeerActivated(existing.id, 'accept_rejected_override').catch(err =>
|
||||
console.error('[federation] onPeerActivated from /peer/accept (rejected override) failed:', err)
|
||||
);
|
||||
|
||||
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
|
||||
}
|
||||
if (existing.status === 'awaiting_approval') {
|
||||
// Spec §3.5: token verification gates the awaiting_approval → active
|
||||
// promotion. Without proof the inbound came from the remote's
|
||||
// /approve endpoint, an adversarial timing-knowledge attack or a
|
||||
// bug-prone background code path could falsely flip this row to
|
||||
// active. The token is single-use entropy issued in the 202 we
|
||||
// returned when the remote's outbound /peer/accept first hit our
|
||||
// queue — only their /approve endpoint forwards it.
|
||||
const tokenValid =
|
||||
typeof existing.approvalToken === 'string' &&
|
||||
existing.approvalToken.length > 0 &&
|
||||
existing.approvalToken === inboundToken;
|
||||
|
||||
if (tokenValid) {
|
||||
db.update(schema.federationPeers)
|
||||
.set({
|
||||
hmacSecret,
|
||||
instanceName: reqInstanceName ?? null,
|
||||
peerInstanceId: reqInstanceId ?? null,
|
||||
status: 'active',
|
||||
lastSeenAt: Date.now(),
|
||||
approvalToken: null,
|
||||
})
|
||||
.where(eq(schema.federationPeers.id, existing.id))
|
||||
.run();
|
||||
|
||||
// Clean up any stale approval-request row for this origin (e.g.,
|
||||
// queued debris from a prior bypass attempt that did not promote).
|
||||
db.delete(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
|
||||
.run();
|
||||
|
||||
for (const uid of connectionManager.getAllOnlineUserIds()) {
|
||||
connectionManager.sendToUser(uid, {
|
||||
type: 'federation_peer_active' as const,
|
||||
peerOrigin: sourceOrigin,
|
||||
});
|
||||
}
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
onPeerActivated(existing.id, 'accept_awaiting_approval').catch(err =>
|
||||
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err)
|
||||
);
|
||||
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
|
||||
}
|
||||
|
||||
// Token absent or mismatched. Cannot prove mutual approval.
|
||||
if (autoAccept === 1) {
|
||||
// We accept any inbound anyway — promoting here is no weaker than
|
||||
// accepting a fresh handshake from a new peer. Clear the stored
|
||||
// token (moot now) and proceed.
|
||||
db.update(schema.federationPeers)
|
||||
.set({
|
||||
hmacSecret,
|
||||
instanceName: reqInstanceName ?? null,
|
||||
peerInstanceId: reqInstanceId ?? null,
|
||||
status: 'active',
|
||||
lastSeenAt: Date.now(),
|
||||
approvalToken: null,
|
||||
})
|
||||
.where(eq(schema.federationPeers.id, existing.id))
|
||||
.run();
|
||||
|
||||
for (const uid of connectionManager.getAllOnlineUserIds()) {
|
||||
connectionManager.sendToUser(uid, {
|
||||
type: 'federation_peer_active' as const,
|
||||
peerOrigin: sourceOrigin,
|
||||
});
|
||||
}
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
onPeerActivated(existing.id, 'accept_awaiting_approval_fallback').catch(err =>
|
||||
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval fallback) failed:', err)
|
||||
);
|
||||
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
|
||||
}
|
||||
|
||||
// autoAccept=0 + unverifiable inbound → queue as new approval-request.
|
||||
// Existing awaiting_approval row stays untouched; the new approval-
|
||||
// request lets the local admin decide whether to honor this inbound.
|
||||
return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null);
|
||||
}
|
||||
// Pending — update with new secret and activate
|
||||
db.update(schema.federationPeers)
|
||||
.set({
|
||||
hmacSecret,
|
||||
instanceName: reqInstanceName ?? null,
|
||||
peerInstanceId: reqInstanceId ?? null,
|
||||
status: 'active',
|
||||
lastSeenAt: Date.now(),
|
||||
})
|
||||
.where(eq(schema.federationPeers.id, existing.id))
|
||||
.run();
|
||||
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
onPeerActivated(existing.id, 'accept_pending').catch(err =>
|
||||
console.error('[federation] onPeerActivated from /peer/accept (pending) failed:', err)
|
||||
);
|
||||
|
||||
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
|
||||
}
|
||||
|
||||
// New peer — create and activate
|
||||
const peerId = generateSnowflake();
|
||||
db.insert(schema.federationPeers).values({
|
||||
id: peerId,
|
||||
origin: sourceOrigin,
|
||||
hmacSecret,
|
||||
instanceName: reqInstanceName ?? null,
|
||||
peerInstanceId: reqInstanceId ?? null,
|
||||
status: 'active',
|
||||
lastSeenAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
onPeerActivated(peerId, 'accept_new').catch(err =>
|
||||
console.error('[federation] onPeerActivated from /peer/accept (new) failed:', err)
|
||||
);
|
||||
|
||||
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/peer/ensure ──────────────────────────────────────
|
||||
// JWT-authenticated (any user): trigger auto-peering with a remote instance.
|
||||
// Rate-limited per user (3 requests per 15 minutes).
|
||||
app.post<{ Body: { remoteOrigin: string } }>(
|
||||
'/api/federation/peer/ensure',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request, reply) => {
|
||||
const { remoteOrigin: rawOrigin } = request.body ?? {};
|
||||
if (!rawOrigin || typeof rawOrigin !== 'string') {
|
||||
return reply.code(400).send({ error: 'remoteOrigin is required', statusCode: 400 });
|
||||
}
|
||||
|
||||
const remoteOrigin = validateOrigin(rawOrigin);
|
||||
if (!remoteOrigin) {
|
||||
return reply.code(400).send({
|
||||
error: 'remoteOrigin must be a valid HTTPS URL (HTTP is only allowed for localhost)',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (isEnsureRateLimited(request.userId)) {
|
||||
return reply.code(429).send({
|
||||
error: 'Too many peering requests — try again later',
|
||||
statusCode: 429,
|
||||
});
|
||||
}
|
||||
|
||||
const { ensurePeered } = await import('../../../utils/federationPeering.js');
|
||||
// NOTE: /peer/ensure is currently only invoked from friend-add client paths
|
||||
// (see packages/web/src/stores/instanceStore.ts ensurePeered references).
|
||||
// The hardcoded reason here is correct TODAY but will become wrong when
|
||||
// DM-to-stranger or space-join grow into the gate. When that happens,
|
||||
// surface the reason and target through the request body instead. Do NOT
|
||||
// silently leave the hardcoding in place when adding a new caller.
|
||||
const result = await ensurePeered(remoteOrigin, {
|
||||
kind: 'user_action',
|
||||
userId: request.userId,
|
||||
reason: 'friend_add',
|
||||
target: remoteOrigin,
|
||||
});
|
||||
|
||||
// NOTE: The internal EnsurePeeredResult status names differ from the client-facing
|
||||
// peeringStatus values. The mapping:
|
||||
// 'active' → 'active' (peer is live)
|
||||
// 'rejected' → 'rejected' (permanently blocked)
|
||||
// 'pending' → 'awaiting_approval' (queued on remote, waiting for admin)
|
||||
// 'failed' → 'pending' (transient error, will retry automatically)
|
||||
// 'admin_required' → 'admin_required' (local outbound gate fired — our admin must approve)
|
||||
// The internal 'pending' means "we got a 202 from the remote — admin hasn't acted yet",
|
||||
// while 'failed' means "network/timeout — the outbox worker will retry next tick".
|
||||
// The client sees 'awaiting_approval' (actionable info) vs 'pending' (transient, will resolve).
|
||||
switch (result.status) {
|
||||
case 'active':
|
||||
return reply.code(200).send({ peeringStatus: 'active', peerId: result.peerId });
|
||||
case 'rejected':
|
||||
return reply.code(200).send({ peeringStatus: 'rejected', error: result.error });
|
||||
case 'pending':
|
||||
return reply.code(200).send({ peeringStatus: 'awaiting_approval', error: result.error });
|
||||
case 'failed':
|
||||
return reply.code(200).send({ peeringStatus: 'pending', error: result.error });
|
||||
case 'admin_required':
|
||||
return reply.code(200).send({ peeringStatus: 'admin_required' });
|
||||
default:
|
||||
return reply.code(200).send({ peeringStatus: 'pending', error: 'Unknown peering result' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/peer/rotate ───────────────────────────────────────
|
||||
// Server-to-server: accept a secret rotation request from a peer instance.
|
||||
// Authenticated via HMAC-SHA256 signature (current secret), NOT JWT.
|
||||
app.post<{ Body: { newSecret: string } }>(
|
||||
'/api/federation/peer/rotate',
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
// 1. Verify HMAC signature
|
||||
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 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// 2. Validate request body
|
||||
const { newSecret } = request.body ?? {};
|
||||
if (!newSecret || typeof newSecret !== 'string' || newSecret.length !== 64 || !/^[0-9a-f]+$/.test(newSecret)) {
|
||||
return reply.code(400).send({ error: 'newSecret must be a 64-character hex string', statusCode: 400 });
|
||||
}
|
||||
|
||||
// 3. Reject if rotation already in progress
|
||||
if (peer.pendingHmacSecret) {
|
||||
return reply.code(409).send({
|
||||
error: 'A secret rotation is already in progress — wait for it to complete',
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Store pending secret and activate grace period
|
||||
db.update(schema.federationPeers)
|
||||
.set({
|
||||
pendingHmacSecret: newSecret,
|
||||
secretRotationAt: Date.now(),
|
||||
})
|
||||
.where(eq(schema.federationPeers.id, peer.id))
|
||||
.run();
|
||||
|
||||
console.log(`[federation] Secret rotation accepted from peer ${peer.origin}`);
|
||||
|
||||
return reply.code(200).send({ accepted: true, gracePeriodMs: 900_000 });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/peer/denied ─────────────────────────────────────
|
||||
// Server-to-server: receive a denial notification from a remote instance.
|
||||
// Authenticated via HMAC-SHA256 signature (the secret we sent in our original
|
||||
// peer/accept request, which the remote stored in their approval queue).
|
||||
app.post<{ Body: { origin: string; reason: 'denied_by_admin' | 'expired'; message?: string } }>(
|
||||
'/api/federation/peer/denied',
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
// Verify HMAC signature
|
||||
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 { origin: senderOrigin, signature, timestamp, nonce } = fedHeaders;
|
||||
|
||||
// Find the local peer for this origin
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.origin, senderOrigin))
|
||||
.get();
|
||||
|
||||
if (!peer) {
|
||||
return reply.code(404).send({ error: 'No peer record for this origin', statusCode: 404 });
|
||||
}
|
||||
|
||||
// Only accept denial for awaiting_approval peers
|
||||
if (peer.status !== 'awaiting_approval') {
|
||||
return reply.code(409).send({
|
||||
error: `Peer is in '${peer.status}' state, not awaiting_approval`,
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify signature using our stored hmacSecret (the one we sent in the original request)
|
||||
const rawBody = JSON.stringify(request.body);
|
||||
const isValid = verifyPeerSignature(rawBody, signature, timestamp, nonce, {
|
||||
hmacSecret: peer.hmacSecret,
|
||||
pendingHmacSecret: null,
|
||||
secretRotationAt: null,
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
return reply.code(401).send({ error: 'Invalid HMAC signature', statusCode: 401 });
|
||||
}
|
||||
|
||||
const { reason, message } = request.body;
|
||||
|
||||
// Transition to rejected
|
||||
db.update(schema.federationPeers)
|
||||
.set({ status: 'rejected' })
|
||||
.where(eq(schema.federationPeers.id, peer.id))
|
||||
.run();
|
||||
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
|
||||
// Push federation_peer_rejected WS event to affected users
|
||||
const entries = db
|
||||
.select({
|
||||
contextId: schema.federationOutbox.contextId,
|
||||
contextType: schema.federationOutbox.contextType,
|
||||
})
|
||||
.from(schema.federationOutbox)
|
||||
.where(eq(schema.federationOutbox.peerId, peer.id))
|
||||
.all();
|
||||
|
||||
const contextMap = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
contextMap.set(entry.contextId, entry.contextType);
|
||||
}
|
||||
|
||||
// Purge outbox entries
|
||||
db.delete(schema.federationOutbox)
|
||||
.where(eq(schema.federationOutbox.peerId, peer.id))
|
||||
.run();
|
||||
|
||||
// Build and send WS event
|
||||
if (contextMap.size > 0) {
|
||||
const { pushPeerRejectedEvent } = await import('../../../utils/federationWorker.js');
|
||||
pushPeerRejectedEvent(
|
||||
senderOrigin,
|
||||
contextMap,
|
||||
message || (reason === 'expired'
|
||||
? 'Request expired — no response from admin within 30 days'
|
||||
: 'Request denied by admin'),
|
||||
);
|
||||
}
|
||||
|
||||
return reply.code(200).send({ acknowledged: true });
|
||||
},
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,851 @@
|
||||
import path from 'node:path';
|
||||
import { config } from '../../../config.js';
|
||||
import { getDb, getRawDb, schema } from '../../../db/index.js';
|
||||
import { buildFederationHeaders, getOurOrigin, parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js';
|
||||
import { getInstanceId } from '../../../utils/federationEpoch.js';
|
||||
import { getDmParticipants } from '../../../utils/federationOutbox.js';
|
||||
import { deleteAttachmentFiles } from '../../../utils/fileCleanup.js';
|
||||
import { sanitizeUser } from '../../../utils/sanitize.js';
|
||||
import { collectDeletionBroadcastTargets, tombstoneUser } from '../../../utils/userDeletion.js';
|
||||
import { connectionManager } from '../../../ws/handler.js';
|
||||
import { and, eq, isNull, or } from 'drizzle-orm';
|
||||
import type { FederationIdentityDeleteS2SRequest, FederationRelayAttachment, FederationRelayEvent, FederationRelayRequest, FederationRelayResponse, FederationSyncRequest, FederationSyncResponse } from '@backspace/shared';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { processRelayEvents } from '../events/dispatch.js';
|
||||
import { extractDomain } from '../identity.js';
|
||||
import { resolveLocalOrigin } from '../origin.js';
|
||||
import { isNonceDuplicate, isRelayRateLimited } from '../rateLimits.js';
|
||||
|
||||
export function registerRelayRoutes(app: FastifyInstance): void {
|
||||
// ─── DELETE /api/federation/identity ──────────────────────────────────────
|
||||
// S2S endpoint: delete a federated user's identity on this instance.
|
||||
// Called by the user's home instance via HMAC-signed request.
|
||||
app.delete<{ Body: FederationIdentityDeleteS2SRequest }>(
|
||||
'/api/federation/identity',
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
// 1. Verify HMAC signature (same pattern as relay endpoint)
|
||||
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 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// Nonce-based 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 });
|
||||
} else {
|
||||
console.warn(`[federation] Peer ${peer.origin} does not support replay protection (no nonce)`);
|
||||
}
|
||||
|
||||
// 2. Validate body
|
||||
const { homeUserId, homeInstance, mode } = request.body;
|
||||
if (!homeUserId || !homeInstance || !['soft', 'full'].includes(mode)) {
|
||||
return reply.code(400).send({ error: 'Invalid request: homeUserId, homeInstance, and mode (soft|full) required', statusCode: 400 });
|
||||
}
|
||||
|
||||
// 3. Resolve the live (non-deleted) federated user.
|
||||
// Must filter isDeleted=0: after a prior deletion + re-federation,
|
||||
// multiple records share the same homeUserId (one deleted, one live).
|
||||
const user = db.select().from(schema.users)
|
||||
.where(and(eq(schema.users.homeUserId, homeUserId), eq(schema.users.isDeleted, 0)))
|
||||
.get();
|
||||
|
||||
// Idempotent: no live user means already deleted or never existed
|
||||
if (!user) {
|
||||
return reply.code(200).send({ success: true });
|
||||
}
|
||||
|
||||
// 4. Attribution guard: only the user's home instance can delete them
|
||||
if (!user.homeInstance || extractDomain(user.homeInstance) !== extractDomain(fedHeaders.origin)) {
|
||||
return reply.code(403).send({ error: 'Attribution mismatch: you can only delete users from your own instance', statusCode: 403 });
|
||||
}
|
||||
|
||||
// Detached (home-orphaned) accounts are sovereign local accounts. The
|
||||
// domain's new incarnation must not delete them by replaying old
|
||||
// homeUserIds. Idempotent 200: from the caller's perspective this
|
||||
// identity does not exist here.
|
||||
if (user.federationHomeOrphaned === 1) {
|
||||
console.log(`[federation] Ignoring S2S identity delete for detached account ${user.id} from ${fedHeaders.origin}`);
|
||||
return reply.code(200).send({ success: true });
|
||||
}
|
||||
|
||||
// 5. Check for owned spaces
|
||||
const ownedSpaces = db.select({ id: schema.spaces.id, name: schema.spaces.name })
|
||||
.from(schema.spaces)
|
||||
.where(eq(schema.spaces.ownerId, user.id))
|
||||
.all();
|
||||
if (ownedSpaces.length > 0) {
|
||||
return reply.code(409).send({ error: 'owns_spaces', ownedSpaces, statusCode: 409 });
|
||||
}
|
||||
|
||||
// 6. Collect broadcast targets BEFORE deletion removes memberships
|
||||
const { memberSpaceIds, targetUserIds } = collectDeletionBroadcastTargets(user.id);
|
||||
|
||||
// 7. Execute deletion
|
||||
const filesToDelete = tombstoneUser(user.id, { purgeContent: mode === 'full' });
|
||||
|
||||
// 8. Clean up files from disk
|
||||
deleteAttachmentFiles(filesToDelete.map(f => ({ filename: f })));
|
||||
|
||||
// 9. Broadcast member_left to other connected clients for each space
|
||||
for (const spaceId of memberSpaceIds) {
|
||||
connectionManager.sendToSpace(spaceId, {
|
||||
type: 'member_left',
|
||||
spaceId,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
// 10. Broadcast user_updated with sanitized deleted user data
|
||||
const deletedRow = db.select().from(schema.users).where(eq(schema.users.id, user.id)).get();
|
||||
if (deletedRow) {
|
||||
const deletedUser = sanitizeUser(deletedRow);
|
||||
const userUpdatedEvent = { type: 'user_updated' as const, user: deletedUser };
|
||||
for (const uid of targetUserIds) {
|
||||
connectionManager.sendToUser(uid, userUpdatedEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// 11. Force-disconnect WS if somehow still connected (unlikely but safe)
|
||||
connectionManager.forceDisconnectUser(user.id);
|
||||
|
||||
console.log(`[federation] Identity deleted for user ${user.id} (${user.username}) via S2S from ${fedHeaders.origin}, mode=${mode}`);
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/relay ────────────────────────────────────────────
|
||||
// Server-to-server: receive relayed DM events from a peer instance.
|
||||
// Authenticated via HMAC-SHA256 signature, NOT JWT.
|
||||
app.post<{ Body: FederationRelayRequest }>(
|
||||
'/api/federation/relay',
|
||||
{ bodyLimit: 10 * 1024 * 1024 },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
// 1. Verify HMAC signature
|
||||
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 });
|
||||
}
|
||||
|
||||
// 1b. Per-peer rate limiting (before expensive HMAC verification)
|
||||
if (isRelayRateLimited(peer.origin)) {
|
||||
return reply.code(429).send({ error: 'Rate limit exceeded', statusCode: 429 });
|
||||
}
|
||||
|
||||
// Serialize body back to JSON for HMAC verification (we control both sides)
|
||||
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 });
|
||||
}
|
||||
|
||||
// 1b-epoch. Fast-path baseline population (design §3.2). The signature just
|
||||
// verified proves the peer holds the current shared secret, so the epoch it
|
||||
// carries in `sourceInstanceId` is authentic. Populate-if-null ONLY: a valid
|
||||
// relay can never carry an epoch differing from a non-null baseline (a
|
||||
// different incarnation implies a different secret that fails HMAC), so we
|
||||
// only ever fill a NULL — never overwrite. This is independent of per-event
|
||||
// processing and does not affect relay accept/reject in any way. Old peers
|
||||
// omit the field → skip (backward-compatible no-op).
|
||||
const claimedEpoch = request.body.sourceInstanceId;
|
||||
if (claimedEpoch && !peer.peerInstanceId) {
|
||||
db.update(schema.federationPeers)
|
||||
.set({ peerInstanceId: claimedEpoch })
|
||||
.where(and(
|
||||
eq(schema.federationPeers.id, peer.id),
|
||||
isNull(schema.federationPeers.peerInstanceId),
|
||||
))
|
||||
.run();
|
||||
}
|
||||
|
||||
// 1c. Nonce-based 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) {
|
||||
// Peer previously sent nonces but this request doesn't have one — reject
|
||||
return reply.code(401).send({ error: 'Nonce required — peer previously supported nonces', statusCode: 401 });
|
||||
} else {
|
||||
console.warn(`[federation] Peer ${peer.origin} does not support replay protection (no nonce)`);
|
||||
}
|
||||
|
||||
// 2. Validate request body shape
|
||||
const body = request.body;
|
||||
if (!body || body.version !== 1 || !Array.isArray(body.events)) {
|
||||
return reply.code(400).send({ error: 'Invalid relay request format', statusCode: 400 });
|
||||
}
|
||||
|
||||
if (body.events.length > 50) {
|
||||
return reply.code(400).send({ error: 'Maximum 50 events per batch', statusCode: 400 });
|
||||
}
|
||||
|
||||
const sourceInstance = body.sourceInstance;
|
||||
if (!sourceInstance || typeof sourceInstance !== 'string') {
|
||||
return reply.code(400).send({ error: 'sourceInstance is required', statusCode: 400 });
|
||||
}
|
||||
|
||||
// 3. Process each event
|
||||
const { accepted, rejected, undeliverable } = await processRelayEvents(body.events, sourceInstance, peer.origin, db);
|
||||
|
||||
// 4. Update peer status
|
||||
db.update(schema.federationPeers)
|
||||
.set({
|
||||
lastSeenAt: Date.now(),
|
||||
consecutiveFailures: 0,
|
||||
...(fedHeaders.nonce && !peer.nonceSupported ? { nonceSupported: 1 } : {}),
|
||||
})
|
||||
.where(eq(schema.federationPeers.id, peer.id))
|
||||
.run();
|
||||
|
||||
// 5. Return response with max upload size info
|
||||
const settings = db
|
||||
.select({ maxUploadSizeBytes: schema.instanceSettings.maxUploadSizeBytes })
|
||||
.from(schema.instanceSettings)
|
||||
.where(eq(schema.instanceSettings.id, 1))
|
||||
.get();
|
||||
|
||||
const response: FederationRelayResponse = {
|
||||
accepted,
|
||||
rejected,
|
||||
maxUploadSize: settings?.maxUploadSizeBytes ?? config.maxUploadSize,
|
||||
...(undeliverable.length > 0 ? { undeliverable } : {}),
|
||||
};
|
||||
|
||||
return reply.code(200).send(response);
|
||||
},
|
||||
);
|
||||
|
||||
// ─── POST /api/federation/epoch ────────────────────────────────────────────
|
||||
// Server-to-server: return this instance's persistent epoch (instance_id).
|
||||
// Authenticated via HMAC-SHA256 signature on the REQUEST (only a peer holding
|
||||
// the shared secret may call it), and the RESPONSE body is HMAC-SIGNED with
|
||||
// the same secret so the caller can verify the epoch it newly trusts before
|
||||
// writing it as the peer's baseline (design §3.2 / §9). The value itself
|
||||
// (instanceId) is already public via /instance/info; signing is for
|
||||
// baseline-integrity, not confidentiality.
|
||||
app.post(
|
||||
'/api/federation/epoch',
|
||||
{ bodyLimit: 4 * 1024 },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
// 1. Parse and require federation headers (mirror relay/users-lookup).
|
||||
const fedHeaders = parseFederationHeaders(request.headers as Record<string, string | string[] | undefined>);
|
||||
if (!fedHeaders) {
|
||||
return reply.code(400).send({ error: 'Missing or malformed federation headers', statusCode: 400 });
|
||||
}
|
||||
|
||||
// 2. Resolve the peer by origin. Reject unknown or revoked peers.
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.origin, fedHeaders.origin))
|
||||
.get();
|
||||
if (!peer || peer.status === 'revoked') {
|
||||
return reply.code(403).send({ error: 'Not peered', statusCode: 403 });
|
||||
}
|
||||
|
||||
// 3. Verify the inbound request signature (honours rotation grace).
|
||||
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 });
|
||||
}
|
||||
|
||||
// 4. Sign the response body with the peer's shared secret and return it.
|
||||
const responseBody = JSON.stringify({ instanceId: getInstanceId() });
|
||||
const sigHeaders = buildFederationHeaders(responseBody, peer.hmacSecret, getOurOrigin());
|
||||
reply.headers({
|
||||
'X-Federation-Signature': sigHeaders['X-Federation-Signature'],
|
||||
'X-Federation-Timestamp': sigHeaders['X-Federation-Timestamp'],
|
||||
'X-Federation-Nonce': sigHeaders['X-Federation-Nonce'],
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
return reply.code(200).send(responseBody);
|
||||
},
|
||||
);
|
||||
|
||||
// ─── 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.
|
||||
// Authenticated via HMAC-SHA256 signature, same as /relay.
|
||||
app.post<{ Body: FederationSyncRequest }>(
|
||||
'/api/federation/sync',
|
||||
{ bodyLimit: 1024 * 64 },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const rawDb = getRawDb();
|
||||
|
||||
// 1. Verify HMAC signature
|
||||
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 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// 1b. Nonce-based 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 });
|
||||
} else {
|
||||
console.warn(`[federation] Peer ${peer.origin} does not support replay protection (no nonce) [sync]`);
|
||||
}
|
||||
|
||||
// Ratchet: mark peer as nonce-supporting if this is the first nonce we've seen
|
||||
if (fedHeaders.nonce && !peer.nonceSupported) {
|
||||
db.update(schema.federationPeers)
|
||||
.set({ nonceSupported: 1 })
|
||||
.where(eq(schema.federationPeers.id, peer.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
// 2. Validate & normalize request body
|
||||
const body = request.body;
|
||||
if (!body || typeof body.sinceTimestamp !== 'number' || body.sinceTimestamp < 0) {
|
||||
return reply.code(400).send({ error: 'sinceTimestamp must be a non-negative number', statusCode: 400 });
|
||||
}
|
||||
|
||||
const sinceTimestamp = body.sinceTimestamp;
|
||||
const dmChannelIdFilter = body.dmChannelId && typeof body.dmChannelId === 'string' ? body.dmChannelId : null;
|
||||
const federatedIdFilter = body.federatedId && typeof body.federatedId === 'string' ? body.federatedId : null;
|
||||
const contextTypeFilter = body.contextType && typeof body.contextType === 'string'
|
||||
? body.contextType as 'dm' | 'friend' | 'profile'
|
||||
: null;
|
||||
|
||||
// Clamp limit: min 1, max 500, default 100
|
||||
let limit = typeof body.limit === 'number' ? body.limit : 100;
|
||||
limit = Math.max(1, Math.min(500, Math.floor(limit)));
|
||||
|
||||
// 3. Query mutation log — branch by contextType
|
||||
let mutationRows: Array<{
|
||||
id: string;
|
||||
entity_id: string;
|
||||
context_id: string;
|
||||
context_type: string;
|
||||
mutation_type: string;
|
||||
mutated_at: number;
|
||||
payload: string | null;
|
||||
}>;
|
||||
|
||||
// Maps local DM channel ID → federatedId for O(1) lookup in serializers.
|
||||
// Only populated in the DM branch (friend/profile branches don't need it).
|
||||
let channelFederatedIdMap = new Map<string, string>();
|
||||
|
||||
// Friend-branch pagination must be computed from PRE-filter rows —
|
||||
// filtering in place would stall the checkpoint / drop pages (spec §3.2).
|
||||
let prefilterCount: number | null = null;
|
||||
let prefilterLastTs: number | null = null;
|
||||
|
||||
if (contextTypeFilter === 'friend') {
|
||||
// ── Friend event sync: relevance-scoped to the requesting peer ──
|
||||
const fetchedFriendRows = rawDb.prepare(`
|
||||
SELECT id, entity_id, context_id, context_type, mutation_type, mutated_at, payload
|
||||
FROM federation_mutation_log
|
||||
WHERE context_type = 'friend' AND mutated_at > ?
|
||||
ORDER BY mutated_at ASC
|
||||
LIMIT ?
|
||||
`).all(sinceTimestamp, limit) as typeof mutationRows;
|
||||
|
||||
prefilterCount = fetchedFriendRows.length;
|
||||
prefilterLastTs = fetchedFriendRows.length > 0
|
||||
? fetchedFriendRows[fetchedFriendRows.length - 1]!.mutated_at
|
||||
: null;
|
||||
|
||||
const peerDomainFriend = extractDomain(peer.origin).toLowerCase();
|
||||
const normHome = `lower(replace(replace(coalesce(home_instance, ''), 'https://', ''), 'http://', ''))`;
|
||||
const localRowStmt = rawDb.prepare(`
|
||||
SELECT is_deleted, federation_home_orphaned FROM users
|
||||
WHERE home_user_id = ? AND ${normHome} = ?
|
||||
`);
|
||||
|
||||
// An event qualifies iff at least one side is homed at the requester's
|
||||
// domain AND that side, when it resolves to a local row, is live and
|
||||
// non-detached. A detached/tombstoned row belongs to a dead incarnation
|
||||
// of the requester, not to the requester (spec §3.2).
|
||||
const sideQualifies = (side: { homeUserId?: string; homeInstance?: string } | undefined): boolean => {
|
||||
if (!side?.homeUserId || !side.homeInstance) return false;
|
||||
if (extractDomain(side.homeInstance).toLowerCase() !== peerDomainFriend) return false;
|
||||
const local = localRowStmt.get(side.homeUserId, peerDomainFriend) as
|
||||
{ is_deleted: number; federation_home_orphaned: number } | undefined;
|
||||
if (local && (local.is_deleted === 1 || local.federation_home_orphaned === 1)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
mutationRows = fetchedFriendRows.filter((row) => {
|
||||
if (!row.payload) return false;
|
||||
let friendship: { from?: { homeUserId?: string; homeInstance?: string }; to?: { homeUserId?: string; homeInstance?: string } } | undefined;
|
||||
try {
|
||||
friendship = (JSON.parse(row.payload) as { friendship?: typeof friendship }).friendship;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!friendship) return false;
|
||||
return sideQualifies(friendship.from) || sideQualifies(friendship.to);
|
||||
});
|
||||
} else if (contextTypeFilter === 'profile') {
|
||||
// ── Profile event sync: no DM channel logic needed ──
|
||||
mutationRows = rawDb.prepare(`
|
||||
SELECT id, entity_id, context_id, context_type, mutation_type, mutated_at, payload
|
||||
FROM federation_mutation_log
|
||||
WHERE context_type = 'profile' AND mutated_at > ?
|
||||
ORDER BY mutated_at ASC
|
||||
LIMIT ?
|
||||
`).all(sinceTimestamp, limit) as typeof mutationRows;
|
||||
} else {
|
||||
// ── DM sync path ──
|
||||
// Determine which DM channels to sync.
|
||||
// Use federated_id: any channel with a federated ID is a federated DM
|
||||
// that should be synced. The peer's relay endpoint will create the channel
|
||||
// if it doesn't exist, or match by federated_id if it does.
|
||||
// Relevance scoping (dead-incarnation spec §3.2): only offer channels
|
||||
// with at least one LIVE member homed at the requesting peer's domain.
|
||||
// A reset peer's former users are detached (federation_home_orphaned=1)
|
||||
// or tombstoned here — their channels are our history, not the new
|
||||
// incarnation's. Channels not involving the requester at all are none
|
||||
// of its business either (third-instance over-broadcast).
|
||||
const peerDomain = extractDomain(peer.origin).toLowerCase();
|
||||
const sharedChannelRows = rawDb.prepare(`
|
||||
SELECT DISTINCT c.id as dm_channel_id, c.federated_id
|
||||
FROM dm_channels c
|
||||
JOIN dm_members m ON m.dm_channel_id = c.id
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE c.federated_id IS NOT NULL AND c.deleted_at IS NULL
|
||||
AND u.is_deleted = 0
|
||||
AND u.federation_home_orphaned = 0
|
||||
AND lower(replace(replace(coalesce(u.home_instance, ''), 'https://', ''), 'http://', '')) = ?
|
||||
`).all(peerDomain) as Array<{ dm_channel_id: string; federated_id: string }>;
|
||||
|
||||
const sharedChannelIds = sharedChannelRows.map(r => r.dm_channel_id);
|
||||
channelFederatedIdMap = new Map<string, string>(
|
||||
sharedChannelRows.map(r => [r.dm_channel_id, r.federated_id])
|
||||
);
|
||||
|
||||
// If filtering by federatedId, resolve to local channel ID
|
||||
let effectiveChannelFilter = dmChannelIdFilter;
|
||||
if (federatedIdFilter && !effectiveChannelFilter) {
|
||||
const fedChannel = rawDb.prepare(`
|
||||
SELECT id FROM dm_channels WHERE federated_id = ?
|
||||
`).get(federatedIdFilter) as { id: string } | undefined;
|
||||
if (fedChannel) {
|
||||
effectiveChannelFilter = fedChannel.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (sharedChannelIds.length === 0) {
|
||||
const syncResponse: FederationSyncResponse = {
|
||||
events: [],
|
||||
hasMore: false,
|
||||
checkpoint: sinceTimestamp,
|
||||
};
|
||||
return reply.code(200).send(syncResponse);
|
||||
}
|
||||
|
||||
// 4. Query mutation log for the relevant channels
|
||||
// Return mutations for ALL locally-created messages (source_instance IS NULL).
|
||||
// This includes messages by replicated users (e.g., Heidi browsing orbit)
|
||||
// because they were created on THIS instance and need to be synced to the peer.
|
||||
if (effectiveChannelFilter) {
|
||||
// Validate that the requested channel is actually shared with this peer
|
||||
if (!sharedChannelIds.includes(effectiveChannelFilter)) {
|
||||
const syncResponse: FederationSyncResponse = {
|
||||
events: [],
|
||||
hasMore: false,
|
||||
checkpoint: sinceTimestamp,
|
||||
};
|
||||
return reply.code(200).send(syncResponse);
|
||||
}
|
||||
|
||||
mutationRows = rawDb.prepare(`
|
||||
SELECT ml.id, ml.entity_id, ml.context_id, ml.context_type, ml.mutation_type, ml.mutated_at, ml.payload
|
||||
FROM federation_mutation_log ml
|
||||
LEFT JOIN dm_messages dm ON ml.entity_id = dm.id
|
||||
WHERE ml.context_id = ?
|
||||
AND ml.context_type = 'dm'
|
||||
AND ml.mutated_at > ?
|
||||
AND (dm.id IS NOT NULL OR ml.mutation_type IN (
|
||||
'delete', 'member_add', 'member_remove', 'ownership_transfer',
|
||||
'dm_close', 'dm_reopen', 'read_state_update', 'file_rejected'
|
||||
))
|
||||
ORDER BY ml.mutated_at ASC
|
||||
LIMIT ?
|
||||
`).all(effectiveChannelFilter, sinceTimestamp, limit) as typeof mutationRows;
|
||||
|
||||
// For delete mutations, the dm_messages row won't exist — handle separately
|
||||
const deleteMutations = rawDb.prepare(`
|
||||
SELECT ml.id, ml.entity_id, ml.context_id, ml.context_type, ml.mutation_type, ml.mutated_at, ml.payload
|
||||
FROM federation_mutation_log ml
|
||||
WHERE ml.context_id = ?
|
||||
AND ml.context_type = 'dm'
|
||||
AND ml.mutated_at > ?
|
||||
AND ml.mutation_type = 'delete'
|
||||
AND ml.entity_id NOT IN (SELECT dm.id FROM dm_messages dm WHERE dm.id = ml.entity_id)
|
||||
ORDER BY ml.mutated_at ASC
|
||||
LIMIT ?
|
||||
`).all(effectiveChannelFilter, sinceTimestamp, limit) as typeof mutationRows;
|
||||
|
||||
// Merge, deduplicate, sort, and re-limit
|
||||
const seen = new Set(mutationRows.map(r => r.id));
|
||||
for (const row of deleteMutations) {
|
||||
if (!seen.has(row.id)) {
|
||||
mutationRows.push(row);
|
||||
seen.add(row.id);
|
||||
}
|
||||
}
|
||||
mutationRows.sort((a, b) => a.mutated_at - b.mutated_at);
|
||||
if (mutationRows.length > limit) {
|
||||
mutationRows = mutationRows.slice(0, limit);
|
||||
}
|
||||
} else {
|
||||
// All shared channels — build IN clause with placeholders
|
||||
const placeholders = sharedChannelIds.map(() => '?').join(',');
|
||||
|
||||
mutationRows = rawDb.prepare(`
|
||||
SELECT ml.id, ml.entity_id, ml.context_id, ml.context_type, ml.mutation_type, ml.mutated_at, ml.payload
|
||||
FROM federation_mutation_log ml
|
||||
LEFT JOIN dm_messages dm ON ml.entity_id = dm.id
|
||||
WHERE ml.context_id IN (${placeholders})
|
||||
AND ml.context_type = 'dm'
|
||||
AND ml.mutated_at > ?
|
||||
AND (dm.id IS NOT NULL OR ml.mutation_type IN (
|
||||
'delete', 'member_add', 'member_remove', 'ownership_transfer',
|
||||
'dm_close', 'dm_reopen', 'read_state_update', 'file_rejected'
|
||||
))
|
||||
ORDER BY ml.mutated_at ASC
|
||||
LIMIT ?
|
||||
`).all(...sharedChannelIds, sinceTimestamp, limit) as typeof mutationRows;
|
||||
|
||||
// For delete mutations, the dm_messages row won't exist — handle separately
|
||||
const deleteMutations = rawDb.prepare(`
|
||||
SELECT ml.id, ml.entity_id, ml.context_id, ml.context_type, ml.mutation_type, ml.mutated_at, ml.payload
|
||||
FROM federation_mutation_log ml
|
||||
WHERE ml.context_id IN (${placeholders})
|
||||
AND ml.context_type = 'dm'
|
||||
AND ml.mutated_at > ?
|
||||
AND ml.mutation_type = 'delete'
|
||||
AND ml.entity_id NOT IN (SELECT dm.id FROM dm_messages dm WHERE dm.id = ml.entity_id)
|
||||
ORDER BY ml.mutated_at ASC
|
||||
LIMIT ?
|
||||
`).all(...sharedChannelIds, sinceTimestamp, limit) as typeof mutationRows;
|
||||
|
||||
// Merge, deduplicate, sort, and re-limit
|
||||
const seen = new Set(mutationRows.map(r => r.id));
|
||||
for (const row of deleteMutations) {
|
||||
if (!seen.has(row.id)) {
|
||||
mutationRows.push(row);
|
||||
seen.add(row.id);
|
||||
}
|
||||
}
|
||||
mutationRows.sort((a, b) => a.mutated_at - b.mutated_at);
|
||||
if (mutationRows.length > limit) {
|
||||
mutationRows = mutationRows.slice(0, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Build response events from mutation log entries
|
||||
const events: FederationRelayEvent[] = [];
|
||||
|
||||
for (const mutation of mutationRows) {
|
||||
const mutationType = mutation.mutation_type as 'create' | 'update' | 'delete' | 'reaction_add' | 'reaction_remove'
|
||||
| 'member_add' | 'member_remove' | 'ownership_transfer'
|
||||
| 'friend_request_create' | 'friend_request_update' | 'friend_request_cancel'
|
||||
| 'friend_add' | 'friend_remove'
|
||||
| 'dm_close' | 'dm_reopen' | 'read_state_update' | 'file_rejected'
|
||||
| 'profile_update';
|
||||
|
||||
if (['member_add', 'member_remove', 'ownership_transfer',
|
||||
'friend_request_create', 'friend_request_update', 'friend_request_cancel',
|
||||
'friend_add', 'friend_remove'].includes(mutationType)) {
|
||||
// Membership and friend mutations store the full event in the payload
|
||||
const payload = mutation.payload ? JSON.parse(mutation.payload) : {};
|
||||
events.push({
|
||||
eventType: mutationType as FederationRelayEvent['eventType'],
|
||||
contextType: (mutation.context_type ?? 'dm') as 'dm' | 'friend' | 'profile',
|
||||
...(mutation.context_type === 'dm' || !mutation.context_type ? { dmChannelId: mutation.context_id } : {}),
|
||||
messageId: mutation.entity_id,
|
||||
encryptionVersion: 0,
|
||||
timestamp: mutation.mutated_at,
|
||||
...payload,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mutationType === 'delete') {
|
||||
// For deletes, we don't need the message content — just the ID and channel
|
||||
events.push({
|
||||
eventType: 'delete',
|
||||
dmChannelId: mutation.context_id,
|
||||
messageId: mutation.entity_id,
|
||||
encryptionVersion: 0,
|
||||
timestamp: mutation.mutated_at,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mutationType === 'reaction_add' || mutationType === 'reaction_remove') {
|
||||
// Use the stored payload from the mutation log
|
||||
if (mutation.payload) {
|
||||
let reactionData: { userId: string; homeUserId: string; homeInstance?: string; emoji: string; createdAt?: number } | null = null;
|
||||
try {
|
||||
reactionData = JSON.parse(mutation.payload) as { userId: string; homeUserId: string; homeInstance?: string; emoji: string; createdAt?: number };
|
||||
} catch {
|
||||
// Skip malformed payload
|
||||
continue;
|
||||
}
|
||||
|
||||
events.push({
|
||||
eventType: mutationType,
|
||||
dmChannelId: mutation.context_id,
|
||||
messageId: mutation.entity_id,
|
||||
encryptionVersion: 0,
|
||||
timestamp: mutation.mutated_at,
|
||||
reaction: {
|
||||
userId: reactionData.userId,
|
||||
homeUserId: reactionData.homeUserId,
|
||||
homeInstance: reactionData.homeInstance || getOurOrigin(),
|
||||
emoji: reactionData.emoji,
|
||||
createdAt: reactionData.createdAt ?? mutation.mutated_at,
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mutationType === 'dm_close' || mutationType === 'dm_reopen') {
|
||||
if (!mutation.payload) continue;
|
||||
let dmCloseReopenPayload: { homeUserId: string; homeInstance: string } | null = null;
|
||||
try { dmCloseReopenPayload = JSON.parse(mutation.payload); } catch { continue; }
|
||||
if (!dmCloseReopenPayload) continue;
|
||||
const fedIdCloseReopen = channelFederatedIdMap.get(mutation.context_id);
|
||||
if (!fedIdCloseReopen) continue;
|
||||
events.push({
|
||||
eventType: mutationType,
|
||||
dmChannelId: mutation.context_id,
|
||||
messageId: mutation.entity_id,
|
||||
federatedId: fedIdCloseReopen,
|
||||
encryptionVersion: 0,
|
||||
timestamp: mutation.mutated_at,
|
||||
dmCloseReopen: dmCloseReopenPayload,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mutationType === 'read_state_update') {
|
||||
if (!mutation.payload) continue;
|
||||
let readState: NonNullable<FederationRelayEvent['readState']> | null = null;
|
||||
try { readState = JSON.parse(mutation.payload) as NonNullable<FederationRelayEvent['readState']>; } catch { continue; }
|
||||
if (!readState) continue;
|
||||
const fedIdReadState = channelFederatedIdMap.get(mutation.context_id);
|
||||
if (!fedIdReadState) continue;
|
||||
events.push({
|
||||
eventType: 'read_state_update',
|
||||
dmChannelId: mutation.context_id,
|
||||
messageId: mutation.entity_id,
|
||||
federatedId: fedIdReadState,
|
||||
encryptionVersion: 0,
|
||||
timestamp: mutation.mutated_at,
|
||||
readState,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mutationType === 'file_rejected') {
|
||||
if (!mutation.payload) continue;
|
||||
let fileRejectedPayload: {
|
||||
attachmentId: string;
|
||||
sourceFilename: string;
|
||||
rejectionReason: string;
|
||||
rejectionLimit: number;
|
||||
affectedUserIds: string[];
|
||||
} | null = null;
|
||||
try { fileRejectedPayload = JSON.parse(mutation.payload); } catch { continue; }
|
||||
if (!fileRejectedPayload) continue;
|
||||
events.push({
|
||||
eventType: 'file_rejected',
|
||||
dmChannelId: mutation.context_id,
|
||||
messageId: mutation.entity_id,
|
||||
encryptionVersion: 0,
|
||||
timestamp: mutation.mutated_at,
|
||||
attachmentId: fileRejectedPayload.attachmentId,
|
||||
sourceFilename: fileRejectedPayload.sourceFilename,
|
||||
rejectionReason: fileRejectedPayload.rejectionReason,
|
||||
rejectionLimit: fileRejectedPayload.rejectionLimit,
|
||||
affectedUserIds: fileRejectedPayload.affectedUserIds,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mutationType === 'profile_update') {
|
||||
if (!mutation.payload) continue;
|
||||
let profileOuter: { profileUpdate?: NonNullable<FederationRelayEvent['profileUpdate']> } | null = null;
|
||||
try { profileOuter = JSON.parse(mutation.payload); } catch { continue; }
|
||||
if (!profileOuter?.profileUpdate) continue;
|
||||
events.push({
|
||||
eventType: 'profile_update',
|
||||
contextType: 'profile',
|
||||
messageId: mutation.entity_id,
|
||||
encryptionVersion: 0,
|
||||
timestamp: mutation.mutated_at,
|
||||
profileUpdate: profileOuter.profileUpdate,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// For create and update: fetch the current message state
|
||||
const message = db
|
||||
.select()
|
||||
.from(schema.dmMessages)
|
||||
.where(eq(schema.dmMessages.id, mutation.entity_id))
|
||||
.get();
|
||||
|
||||
if (!message) {
|
||||
// Message was deleted after this create/update mutation was logged — skip it.
|
||||
// The delete mutation will handle the cleanup on the peer side.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the author user to get homeUserId and homeInstance
|
||||
const authorUser = db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, message.userId))
|
||||
.get();
|
||||
|
||||
if (!authorUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const homeUserId = authorUser.homeUserId || authorUser.id;
|
||||
const homeInstance = authorUser.homeInstance || (config.domain ? `https://${config.domain}` : '');
|
||||
|
||||
// Fetch attachments for the message
|
||||
const attachmentRows = db
|
||||
.select()
|
||||
.from(schema.attachments)
|
||||
.where(eq(schema.attachments.dmMessageId, message.id))
|
||||
.all();
|
||||
|
||||
let localOrigin: string;
|
||||
try {
|
||||
localOrigin = resolveLocalOrigin();
|
||||
} catch {
|
||||
localOrigin = config.domain ? `https://${config.domain}` : '';
|
||||
}
|
||||
|
||||
const attachments: FederationRelayAttachment[] = attachmentRows.map(a => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
originalName: a.originalName,
|
||||
mimetype: a.mimetype,
|
||||
size: a.size,
|
||||
width: a.width ?? undefined,
|
||||
height: a.height ?? undefined,
|
||||
duration: a.duration ?? undefined,
|
||||
playable: a.playable ?? null,
|
||||
thumbnailFilename: a.thumbnailFilename ?? undefined,
|
||||
sourceUrl: `${localOrigin}/api/uploads/${a.filename}`,
|
||||
}));
|
||||
|
||||
// Include federatedId for group DMs so the peer uses the correct
|
||||
// channel lookup path instead of computing a 1-on-1 pair hash.
|
||||
const syncChannel = db
|
||||
.select({ federatedId: schema.dmChannels.federatedId, ownerId: schema.dmChannels.ownerId })
|
||||
.from(schema.dmChannels)
|
||||
.where(eq(schema.dmChannels.id, mutation.context_id))
|
||||
.get();
|
||||
|
||||
events.push({
|
||||
eventType: mutationType,
|
||||
...(syncChannel?.federatedId && syncChannel.ownerId ? { federatedId: syncChannel.federatedId } : {}),
|
||||
dmChannelId: mutation.context_id,
|
||||
messageId: message.id,
|
||||
encryptionVersion: 0,
|
||||
timestamp: mutation.mutated_at,
|
||||
participants: getDmParticipants(mutation.context_id),
|
||||
message: {
|
||||
userId: message.userId,
|
||||
homeUserId,
|
||||
homeInstance,
|
||||
content: message.content,
|
||||
replyToId: message.replyToId ?? null,
|
||||
editedAt: message.editedAt ?? null,
|
||||
createdAt: message.createdAt,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Compute pagination metadata — from PRE-filter rows when the friend
|
||||
// branch filtered, so filtered-out events still advance the cursor.
|
||||
const hasMore = (prefilterCount ?? mutationRows.length) >= limit;
|
||||
const checkpoint = prefilterLastTs
|
||||
?? (mutationRows.length > 0
|
||||
? mutationRows[mutationRows.length - 1]!.mutated_at
|
||||
: sinceTimestamp);
|
||||
|
||||
// 7. Update peer last-seen timestamp
|
||||
db.update(schema.federationPeers)
|
||||
.set({ lastSeenAt: Date.now() })
|
||||
.where(eq(schema.federationPeers.id, peer.id))
|
||||
.run();
|
||||
|
||||
const syncResponse: FederationSyncResponse = {
|
||||
events,
|
||||
hasMore,
|
||||
checkpoint,
|
||||
};
|
||||
|
||||
return reply.code(200).send(syncResponse);
|
||||
},
|
||||
);
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user