- 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.
92 lines
2.9 KiB
TypeScript
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;
|
|
}
|