sipher/src/lib/federation/registry.ts
Nixyan d5d7f66f08 feat: enhance social follow functionality and federation integration
- Added support for following users with optional federation URLs, allowing for cross-server interactions.
- Implemented new endpoints for following and unfollowing users, including payload validation and error handling.
- Introduced federation delivery jobs to handle follow requests across different servers.
- Updated database schema to include references for follower and following server URLs.
- Enhanced URL validation to allow localhost during development while maintaining security checks.
- Refactored existing social endpoints to accommodate new follow logic and improve code organization.
2026-03-16 17:04:50 -03:00

92 lines
2.9 KiB
TypeScript

import db from '@/lib/db';
import { serverRegistry } from '@/lib/db/schema';
import { assertSafeUrl } from '@/lib/federation/url-guard';
import createDebug from 'debug';
import { eq } from 'drizzle-orm';
const debug = createDebug('app:federation:registry');
export async function upsertServer(url: string, publicKey: string, encryptionPublicKey: string) {
return await db.insert(serverRegistry).values({
id: crypto.randomUUID(),
url,
publicKey,
encryptionPublicKey,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
isHealthy: true,
}).onConflictDoNothing();
}
export class DiscoveryError extends Error {
constructor(message: string) {
super(message);
this.name = 'DiscoveryError';
}
}
/**
* Fetches a remote server's /discover endpoint, registers it locally,
* and POSTs our own info so the remote registers us back (mutual registration).
* Returns the remote server's encryptionPublicKey on success.
*/
export async function discoverAndRegister(serverUrl: string): Promise<string> {
debug('auto-discovering server %s', serverUrl);
assertSafeUrl(serverUrl);
let remote: { url?: string; publicKey?: string; encryptionPublicKey?: string };
try {
const res = await fetch(serverUrl + '/discover', {
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
throw new DiscoveryError(`GET /discover returned ${res.status}`);
}
remote = await res.json();
} catch (err) {
if (err instanceof DiscoveryError) throw err;
throw new DiscoveryError(`Failed to reach ${serverUrl}/discover: ${err instanceof Error ? err.message : err}`);
}
if (!remote.publicKey || !remote.encryptionPublicKey) {
throw new DiscoveryError(`Server ${serverUrl} returned incomplete keys`);
}
const existing = await db
.select({ publicKey: serverRegistry.publicKey })
.from(serverRegistry)
.where(eq(serverRegistry.url, serverUrl))
.limit(1);
if (existing.length > 0 && existing[0].publicKey !== remote.publicKey) {
throw new DiscoveryError(
`Server ${serverUrl} presented a different public key than what we have on record. ` +
`This may indicate a key rotation issue or a compromised server.`,
);
}
debug('registering remote server %s locally', serverUrl);
await upsertServer(serverUrl, remote.publicKey, remote.encryptionPublicKey);
debug('sending mutual REGISTER to %s', serverUrl);
try {
await fetch(serverUrl + '/discover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method: 'REGISTER',
url: process.env.BETTER_AUTH_URL!,
publicKey: process.env.FEDERATION_PUBLIC_KEY!,
encryptionPublicKey: process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!,
}),
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
debug('mutual REGISTER to %s failed (non-fatal): %s', serverUrl, err instanceof Error ? err.message : err);
}
debug('auto-discovery of %s complete', serverUrl);
return remote.encryptionPublicKey;
}