feat(federation): direction-branched approve and deny for outbound queue
- /approve on outbound: generates HMAC, sends /peer/accept to remote. 200 -> activate peer + onPeerActivated cleanup. 202 -> awaiting_approval, capture token, queue row + subscribers REMAIN. 4xx/5xx/network -> clean up peer row, leave queue for admin retry. - /deny on outbound: fans out kind='denied' notifications, cascade-deletes parent + subscribers, broadcasts admin event. No remote network call. - /approve and /deny on inbound: existing behavior preserved verbatim. - GET /approval-requests: response includes direction; outbound rows carry subscribers[] (joined with users.username, possibly empty). - Removes Task 1's temporary /deny scaffolding guard now that the direction-branched dispatcher handles outbound rows correctly. - Updates docs/systems/federation.md to describe direction-branched flow.
This commit is contained in:
@@ -20,7 +20,7 @@ import { tombstoneUser, collectDeletionBroadcastTargets, collectProfileBroadcast
|
||||
import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/federationOutbox.js';
|
||||
import { onPeerActivated, onPeerDeactivated } from '../utils/federationPeerActivation.js';
|
||||
import { getDmMessageWithUser } from './dm.js';
|
||||
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent } from '@backspace/shared';
|
||||
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent, ApprovalRequestSubscriberSummary, PeeringTriggerReason } from '@backspace/shared';
|
||||
|
||||
/** Fields safe to expose to admin callers (everything except hmacSecret). */
|
||||
interface SanitizedPeer {
|
||||
@@ -317,6 +317,483 @@ function queueApprovalRequest(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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,
|
||||
// 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 from the response body so the
|
||||
// federation panel renders a friendly label. Tolerate omission and
|
||||
// non-JSON bodies — same pattern as performHandshake and /peer/initiate.
|
||||
let remoteInstanceName: string | null = null;
|
||||
try {
|
||||
const body = (await response.json()) as { instanceName?: string | null };
|
||||
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
|
||||
remoteInstanceName = body.instanceName;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON body — leave null.
|
||||
}
|
||||
|
||||
db.update(schema.federationPeers)
|
||||
.set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, 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.
|
||||
*/
|
||||
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,
|
||||
// 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.
|
||||
let remoteInstanceName: string | null = approvalReq.instanceName;
|
||||
try {
|
||||
const body = (await response.json()) as { instanceName?: string | null };
|
||||
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
|
||||
remoteInstanceName = body.instanceName;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON body — keep approvalReq.instanceName (may be null).
|
||||
}
|
||||
|
||||
db.update(schema.federationPeers)
|
||||
.set({
|
||||
status: 'active',
|
||||
lastSeenAt: now,
|
||||
instanceName: remoteInstanceName,
|
||||
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.
|
||||
*/
|
||||
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).
|
||||
*/
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
// 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 async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ─── POST /api/federation/peer/initiate ────────────────────────────────────
|
||||
// Admin-only: start a peering handshake with a remote instance.
|
||||
@@ -1175,6 +1652,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
.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,
|
||||
@@ -1183,11 +1661,50 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
.orderBy(desc(schema.peerApprovalRequests.requestedAt))
|
||||
.all();
|
||||
|
||||
return reply.code(200).send({ requests });
|
||||
// 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] },
|
||||
@@ -1215,158 +1732,18 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
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 (approvalReq.direction === 'outbound') {
|
||||
return await handleOutboundApprove(approvalReq, localOrigin, reply);
|
||||
}
|
||||
|
||||
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,
|
||||
// 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 from the response body so the
|
||||
// federation panel renders a friendly label. Tolerate omission and
|
||||
// non-JSON bodies — same pattern as performHandshake and /peer/initiate.
|
||||
let remoteInstanceName: string | null = null;
|
||||
try {
|
||||
const body = (await response.json()) as { instanceName?: string | null };
|
||||
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
|
||||
remoteInstanceName = body.instanceName;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON body — leave null.
|
||||
}
|
||||
|
||||
db.update(schema.federationPeers)
|
||||
.set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, 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,
|
||||
});
|
||||
}
|
||||
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] },
|
||||
@@ -1384,73 +1761,11 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(404).send({ error: 'Approval request not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
// Outbound rows have no hmac_secret and no remote /peer/denied endpoint to call.
|
||||
// Direction-branched handling lands in Task 7; until then, inbound is the only path here.
|
||||
if (!approvalReq.hmacSecret) {
|
||||
return reply.code(400).send({
|
||||
error: 'Cannot deny an outbound peering request via this endpoint yet — outbound denial handling is not implemented.',
|
||||
statusCode: 400,
|
||||
});
|
||||
if (approvalReq.direction === 'outbound') {
|
||||
return await handleOutboundDeny(approvalReq, reply);
|
||||
}
|
||||
|
||||
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 });
|
||||
return await handleInboundDeny(approvalReq, reply);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user