feat(federation): add client peering integration, transparency indicator, and admin federation settings
Task 13: Hook server-to-server peering into connectToRemote (non-fatal) and add federation API namespace to the client (initiate, peers, revoke). Task 14: Show a transparency notice in the DM welcome header when the other user is on a remote instance, informing that messages are stored on both home instances and are not end-to-end encrypted. Task 15: Add Federation section to the instance settings General panel with DM relay toggle, TTL config, and a live peer list with revoke buttons. Also extends InstanceAdminSettings type and the server settings route to support federationRelayEnabled / federationRelayTtlDays.
This commit is contained in:
@@ -189,6 +189,8 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
gifApiKey: gifKey ? `****${gifKey.slice(-4)}` : undefined,
|
gifApiKey: gifKey ? `****${gifKey.slice(-4)}` : undefined,
|
||||||
gifEnabled: !!gifKey,
|
gifEnabled: !!gifKey,
|
||||||
maxUploadSizeMb: Math.round(maxUploadBytes / (1024 * 1024)),
|
maxUploadSizeMb: Math.round(maxUploadBytes / (1024 * 1024)),
|
||||||
|
federationRelayEnabled: row.federationRelayEnabled === 1,
|
||||||
|
federationRelayTtlDays: row.federationRelayTtlDays,
|
||||||
};
|
};
|
||||||
|
|
||||||
return reply.code(200).send(response);
|
return reply.code(200).send(response);
|
||||||
@@ -235,6 +237,18 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
updateData.maxUploadSizeBytes = Math.round(mb * 1024 * 1024);
|
updateData.maxUploadSizeBytes = Math.round(mb * 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.federationRelayEnabled !== undefined) {
|
||||||
|
updateData.federationRelayEnabled = body.federationRelayEnabled ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.federationRelayTtlDays !== undefined) {
|
||||||
|
const ttl = Number(body.federationRelayTtlDays);
|
||||||
|
if (isNaN(ttl) || !Number.isInteger(ttl) || ttl < 1 || ttl > 365) {
|
||||||
|
return reply.code(400).send({ error: 'federationRelayTtlDays must be an integer between 1 and 365', statusCode: 400 });
|
||||||
|
}
|
||||||
|
updateData.federationRelayTtlDays = ttl;
|
||||||
|
}
|
||||||
|
|
||||||
db.update(schema.instanceSettings).set(updateData).where(eq(schema.instanceSettings.id, 1)).run();
|
db.update(schema.instanceSettings).set(updateData).where(eq(schema.instanceSettings.id, 1)).run();
|
||||||
|
|
||||||
const updatedRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
const updatedRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||||
@@ -251,6 +265,8 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
gifApiKey: updatedGifKey ? `****${updatedGifKey.slice(-4)}` : undefined,
|
gifApiKey: updatedGifKey ? `****${updatedGifKey.slice(-4)}` : undefined,
|
||||||
gifEnabled: !!updatedGifKey,
|
gifEnabled: !!updatedGifKey,
|
||||||
maxUploadSizeMb: Math.round(updatedMaxUploadBytes / (1024 * 1024)),
|
maxUploadSizeMb: Math.round(updatedMaxUploadBytes / (1024 * 1024)),
|
||||||
|
federationRelayEnabled: updatedRow.federationRelayEnabled === 1,
|
||||||
|
federationRelayTtlDays: updatedRow.federationRelayTtlDays,
|
||||||
};
|
};
|
||||||
|
|
||||||
return reply.code(200).send(response);
|
return reply.code(200).send(response);
|
||||||
|
|||||||
@@ -606,6 +606,8 @@ export interface InstanceAdminSettings {
|
|||||||
gifApiKey?: string;
|
gifApiKey?: string;
|
||||||
gifEnabled?: boolean;
|
gifEnabled?: boolean;
|
||||||
maxUploadSizeMb: number;
|
maxUploadSizeMb: number;
|
||||||
|
federationRelayEnabled: boolean;
|
||||||
|
federationRelayTtlDays: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstanceStreamingLimits {
|
export interface InstanceStreamingLimits {
|
||||||
|
|||||||
@@ -198,6 +198,12 @@ export class BackspaceApiClient {
|
|||||||
enabled: () => Promise<{ enabled: boolean }>;
|
enabled: () => Promise<{ enabled: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
readonly federation: {
|
||||||
|
initiatePeering: (data: { remoteOrigin: string }) => Promise<{ peer: { id: string; origin: string; instanceName: string | null; status: string; lastSeenAt: number | null; createdAt: number } }>;
|
||||||
|
peers: () => Promise<{ peers: Array<{ id: string; origin: string; instanceName: string | null; status: string; lastSeenAt: number | null; lastFailureAt: number | null; consecutiveFailures: number | null; lastSyncedAt: number | null; createdAt: number }> }>;
|
||||||
|
revokePeer: (id: string) => Promise<{ success: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
readonly admin: {
|
readonly admin: {
|
||||||
storageStats: () => Promise<StorageStats>;
|
storageStats: () => Promise<StorageStats>;
|
||||||
storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>;
|
storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>;
|
||||||
@@ -615,6 +621,19 @@ export class BackspaceApiClient {
|
|||||||
enabled: () => request<{ enabled: boolean }>('GET', '/gif/enabled'),
|
enabled: () => request<{ enabled: boolean }>('GET', '/gif/enabled'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.federation = {
|
||||||
|
initiatePeering: (data: { remoteOrigin: string }) =>
|
||||||
|
request<{ peer: { id: string; origin: string; instanceName: string | null; status: string; lastSeenAt: number | null; createdAt: number } }>(
|
||||||
|
'POST', '/federation/peer/initiate', data
|
||||||
|
),
|
||||||
|
peers: () =>
|
||||||
|
request<{ peers: Array<{ id: string; origin: string; instanceName: string | null; status: string; lastSeenAt: number | null; lastFailureAt: number | null; consecutiveFailures: number | null; lastSyncedAt: number | null; createdAt: number }> }>(
|
||||||
|
'GET', '/federation/peers'
|
||||||
|
),
|
||||||
|
revokePeer: (id: string) =>
|
||||||
|
request<{ success: boolean }>('DELETE', `/federation/peers/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
this.admin = {
|
this.admin = {
|
||||||
storageStats: () => request<StorageStats>('GET', '/admin/storage/stats'),
|
storageStats: () => request<StorageStats>('GET', '/admin/storage/stats'),
|
||||||
storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'),
|
storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'),
|
||||||
|
|||||||
@@ -387,6 +387,11 @@ function WelcomeHeader({ channelId }: { channelId: string }) {
|
|||||||
<p className="text-txt-secondary text-[14px] mt-1">
|
<p className="text-txt-secondary text-[14px] mt-1">
|
||||||
This is the beginning of your direct message history with <strong>@{username}</strong>.
|
This is the beginning of your direct message history with <strong>@{username}</strong>.
|
||||||
</p>
|
</p>
|
||||||
|
{otherUser?.homeInstance && (
|
||||||
|
<p className="text-xs text-txt-tertiary mt-1">
|
||||||
|
Messages are stored on your and your recipient's home instances. They are not end-to-end encrypted.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{isFriend && otherUser && (
|
{isFriend && otherUser && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useSettingsStore } from '../../../stores/settingsStore';
|
import { useSettingsStore } from '../../../stores/settingsStore';
|
||||||
import { useUIStore } from '../../../stores/uiStore';
|
import { useUIStore } from '../../../stores/uiStore';
|
||||||
import { Toggle } from '../../ui/Toggle';
|
import { Toggle } from '../../ui/Toggle';
|
||||||
|
import { api } from '../../../api/client';
|
||||||
import type { InstanceAdminSettings } from '@backspace/shared';
|
import type { InstanceAdminSettings } from '@backspace/shared';
|
||||||
|
|
||||||
|
interface FederationPeer {
|
||||||
|
id: string;
|
||||||
|
origin: string;
|
||||||
|
instanceName: string | null;
|
||||||
|
status: string;
|
||||||
|
lastSeenAt: number | null;
|
||||||
|
lastFailureAt: number | null;
|
||||||
|
consecutiveFailures: number | null;
|
||||||
|
lastSyncedAt: number | null;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function GeneralPanel() {
|
export function GeneralPanel() {
|
||||||
const instanceSettings = useSettingsStore((s) => s.instanceSettings);
|
const instanceSettings = useSettingsStore((s) => s.instanceSettings);
|
||||||
const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings);
|
const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings);
|
||||||
@@ -16,6 +29,11 @@ export function GeneralPanel() {
|
|||||||
const [gifKeyDirty, setGifKeyDirty] = useState(false);
|
const [gifKeyDirty, setGifKeyDirty] = useState(false);
|
||||||
const [gifKeyDraft, setGifKeyDraft] = useState('');
|
const [gifKeyDraft, setGifKeyDraft] = useState('');
|
||||||
|
|
||||||
|
// Federation peers state
|
||||||
|
const [peers, setPeers] = useState<FederationPeer[]>([]);
|
||||||
|
const [peersLoading, setPeersLoading] = useState(false);
|
||||||
|
const [revokingPeerId, setRevokingPeerId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (instanceSettings) {
|
if (instanceSettings) {
|
||||||
setDraft({ ...instanceSettings });
|
setDraft({ ...instanceSettings });
|
||||||
@@ -24,12 +42,30 @@ export function GeneralPanel() {
|
|||||||
}
|
}
|
||||||
}, [instanceSettings]);
|
}, [instanceSettings]);
|
||||||
|
|
||||||
|
const fetchPeers = useCallback(async () => {
|
||||||
|
setPeersLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await api.federation.peers();
|
||||||
|
setPeers(result.peers);
|
||||||
|
} catch {
|
||||||
|
// Non-critical — peers list may fail on instances without federation routes
|
||||||
|
} finally {
|
||||||
|
setPeersLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPeers();
|
||||||
|
}, [fetchPeers]);
|
||||||
|
|
||||||
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
|
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
|
||||||
|
|
||||||
const baseChanges = instanceSettings && draft
|
const baseChanges = instanceSettings && draft
|
||||||
? draft.instanceName !== instanceSettings.instanceName ||
|
? draft.instanceName !== instanceSettings.instanceName ||
|
||||||
draft.registrationOpen !== instanceSettings.registrationOpen ||
|
draft.registrationOpen !== instanceSettings.registrationOpen ||
|
||||||
draft.discoveryEnabled !== instanceSettings.discoveryEnabled
|
draft.discoveryEnabled !== instanceSettings.discoveryEnabled ||
|
||||||
|
draft.federationRelayEnabled !== instanceSettings.federationRelayEnabled ||
|
||||||
|
draft.federationRelayTtlDays !== instanceSettings.federationRelayTtlDays
|
||||||
: false;
|
: false;
|
||||||
const hasChanges = baseChanges || gifKeyDirty;
|
const hasChanges = baseChanges || gifKeyDirty;
|
||||||
|
|
||||||
@@ -41,6 +77,8 @@ export function GeneralPanel() {
|
|||||||
instanceName: draft!.instanceName,
|
instanceName: draft!.instanceName,
|
||||||
registrationOpen: draft!.registrationOpen,
|
registrationOpen: draft!.registrationOpen,
|
||||||
discoveryEnabled: draft!.discoveryEnabled,
|
discoveryEnabled: draft!.discoveryEnabled,
|
||||||
|
federationRelayEnabled: draft!.federationRelayEnabled,
|
||||||
|
federationRelayTtlDays: draft!.federationRelayTtlDays,
|
||||||
};
|
};
|
||||||
if (gifKeyDirty) {
|
if (gifKeyDirty) {
|
||||||
payload.gifApiKey = gifKeyDraft;
|
payload.gifApiKey = gifKeyDraft;
|
||||||
@@ -63,6 +101,44 @@ export function GeneralPanel() {
|
|||||||
setSaveError('');
|
setSaveError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRevokePeer = async (peerId: string) => {
|
||||||
|
setRevokingPeerId(peerId);
|
||||||
|
try {
|
||||||
|
await api.federation.revokePeer(peerId);
|
||||||
|
setPeers((prev) => prev.filter((p) => p.id !== peerId));
|
||||||
|
addToast('Peer revoked', 'success', 2000);
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Failed to revoke peer', 'warning', 3000);
|
||||||
|
} finally {
|
||||||
|
setRevokingPeerId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRelativeTime = (timestamp: number | null): string => {
|
||||||
|
if (!timestamp) return 'Never';
|
||||||
|
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||||
|
if (seconds < 60) return 'Just now';
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const peerStatusColor = (status: string): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active': return 'bg-status-online/15 text-status-online';
|
||||||
|
case 'pending': return 'bg-accent-amber/15 text-accent-amber';
|
||||||
|
case 'unreachable': return 'bg-accent-rose/15 text-txt-danger';
|
||||||
|
case 'revoked': return 'bg-white/5 text-txt-tertiary';
|
||||||
|
default: return 'bg-white/5 text-txt-tertiary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only show non-revoked peers
|
||||||
|
const visiblePeers = peers.filter((p) => p.status !== 'revoked');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-5" onSubmit={(e) => e.preventDefault()}>
|
<form className="space-y-5" onSubmit={(e) => e.preventDefault()}>
|
||||||
<h2 className="text-lg font-semibold text-txt-primary">General</h2>
|
<h2 className="text-lg font-semibold text-txt-primary">General</h2>
|
||||||
@@ -147,6 +223,94 @@ export function GeneralPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Federation */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Federation</div>
|
||||||
|
<p className="text-xs text-txt-tertiary mb-2">
|
||||||
|
Control DM relay between federated instances. When enabled, DMs with users on peer instances are relayed server-to-server.
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||||
|
<label className="flex items-center justify-between cursor-pointer">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-txt-primary">Enable DM Relay</div>
|
||||||
|
<div className="text-xs text-txt-tertiary mt-0.5">Relay direct messages to and from peer instances</div>
|
||||||
|
</div>
|
||||||
|
<Toggle enabled={draft.federationRelayEnabled} onChange={(v) => setDraft({ ...draft, federationRelayEnabled: v })} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-txt-primary mb-1">Relay TTL (days)</div>
|
||||||
|
<div className="text-xs text-txt-tertiary mb-2">How long relayed messages are retained in the outbox before cleanup</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={draft.federationRelayTtlDays}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = parseInt(e.target.value, 10);
|
||||||
|
if (!isNaN(val) && val >= 1 && val <= 365) {
|
||||||
|
setDraft({ ...draft, federationRelayTtlDays: val });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="input-standard w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Peers list */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="text-sm font-medium text-txt-primary">Peered Instances</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={fetchPeers}
|
||||||
|
disabled={peersLoading}
|
||||||
|
className="text-[11px] text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{peersLoading ? 'Loading...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visiblePeers.length === 0 && !peersLoading && (
|
||||||
|
<div className="text-xs text-txt-tertiary py-2">
|
||||||
|
No federation peers configured. Peers are created automatically when connecting to remote instances.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visiblePeers.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{visiblePeers.map((peer) => (
|
||||||
|
<div key={peer.id} className="flex items-center justify-between rounded-md bg-white/[0.02] px-3 py-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-txt-primary truncate">
|
||||||
|
{peer.instanceName || new URL(peer.origin).host}
|
||||||
|
</span>
|
||||||
|
<span className={`inline-flex items-center text-[10px] font-medium px-1.5 py-0.5 rounded ${peerStatusColor(peer.status)}`}>
|
||||||
|
{peer.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-txt-tertiary truncate">{peer.origin}</div>
|
||||||
|
<div className="text-[11px] text-txt-tertiary">
|
||||||
|
Last seen: {formatRelativeTime(peer.lastSeenAt)}
|
||||||
|
{peer.lastSyncedAt ? ` · Synced: ${formatRelativeTime(peer.lastSyncedAt)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRevokePeer(peer.id)}
|
||||||
|
disabled={revokingPeerId === peer.id}
|
||||||
|
className="ml-3 px-2.5 py-1 text-xs font-medium text-txt-danger bg-accent-rose/10 hover:bg-accent-rose/20 rounded transition-colors disabled:opacity-50 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{revokingPeerId === peer.id ? 'Revoking...' : 'Revoke'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Status messages */}
|
{/* Status messages */}
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
|
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
|
||||||
|
|||||||
@@ -289,6 +289,13 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initiate server-to-server peering for DM relay (non-fatal)
|
||||||
|
try {
|
||||||
|
await api.federation.initiatePeering({ remoteOrigin: origin });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[federation] Peering initiation failed (non-fatal):', err);
|
||||||
|
}
|
||||||
|
|
||||||
// Sync instance list to all instances (fire-and-forget)
|
// Sync instance list to all instances (fire-and-forget)
|
||||||
get().syncInstanceList().catch(() => {});
|
get().syncInstanceList().catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user