diff --git a/packages/server/src/utils/federationOriginResolve.test.ts b/packages/server/src/utils/federationOriginResolve.test.ts new file mode 100644 index 00000000..998ab1d8 --- /dev/null +++ b/packages/server/src/utils/federationOriginResolve.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +let sqlite: Database.Database; +let testDb: ReturnType>; +let mockOurOrigin = 'https://home.test'; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('./federationAuth.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOurOrigin: () => mockOurOrigin }; +}); + +function applyMigrations(db: Database.Database): void { + const dir = path.resolve(__dirname, '../../drizzle'); + for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) { + const sqlText = fs.readFileSync(path.join(dir, f), 'utf8'); + for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedPeer(origin: string): void { + testDb.insert(schema.federationPeers).values({ + id: `peer-${origin}`, + origin, + hmacSecret: 'a'.repeat(64), + status: 'active', + nonceSupported: 1, + createdAt: Date.now(), + consecutiveFailures: 0, + consecutiveAuthFailures: 0, + } as typeof schema.federationPeers.$inferInsert).run(); +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + mockOurOrigin = 'https://home.test'; +}); + +describe('resolveOriginFromHostname', () => { + it('returns stored peer origin on exact host match', async () => { + seedPeer('https://orbit.test'); + const { resolveOriginFromHostname } = await import('./federationOriginResolve.js'); + expect(resolveOriginFromHostname('orbit.test')).toBe('https://orbit.test'); + }); + + it('matches peer origin case-insensitively', async () => { + seedPeer('https://orbit.test'); + const { resolveOriginFromHostname } = await import('./federationOriginResolve.js'); + expect(resolveOriginFromHostname('ORBIT.TEST')).toBe('https://orbit.test'); + }); + + it('mirrors https scheme when no peer matches', async () => { + mockOurOrigin = 'https://home.test'; + const { resolveOriginFromHostname } = await import('./federationOriginResolve.js'); + expect(resolveOriginFromHostname('newpeer.example')).toBe('https://newpeer.example'); + }); + + it('mirrors http scheme for localhost targets', async () => { + mockOurOrigin = 'http://localhost:3005'; + const { resolveOriginFromHostname } = await import('./federationOriginResolve.js'); + expect(resolveOriginFromHostname('localhost:3006')).toBe('http://localhost:3006'); + }); + + it('returns null when validateOrigin rejects http for non-localhost', async () => { + mockOurOrigin = 'http://localhost:3005'; + const { resolveOriginFromHostname } = await import('./federationOriginResolve.js'); + expect(resolveOriginFromHostname('newpeer.example')).toBeNull(); + }); + + it('returns null for empty input', async () => { + const { resolveOriginFromHostname } = await import('./federationOriginResolve.js'); + expect(resolveOriginFromHostname('')).toBeNull(); + }); +}); diff --git a/packages/server/src/utils/federationOriginResolve.ts b/packages/server/src/utils/federationOriginResolve.ts new file mode 100644 index 00000000..e04713f3 --- /dev/null +++ b/packages/server/src/utils/federationOriginResolve.ts @@ -0,0 +1,51 @@ +import { getDb } from '../db/index.js'; +import * as schema from '../db/schema.js'; +import { getOurOrigin } from './federationAuth.js'; +import { validateOrigin } from '../routes/federation.js'; + +/** + * Resolve a typed hostname (e.g., the part after `@` in `alice@orbit.test`) + * into a full peer origin URL suitable for ensurePeered() / fetch(). + * + * Resolution order: + * 1. If a federation_peers row exists whose URL host matches (case-insensitive), + * return that peer's stored origin verbatim. (Authoritative for any peer the + * admin has explicitly configured.) + * 2. Otherwise, mirror getOurOrigin()'s scheme: + * - https://... → https://${hostname} + * - http://... → http://${hostname} (covers dev: localhost:3006) + * Validate via validateOrigin (which rejects http for non-localhost). + * + * Returns null if the result fails validation (e.g., http for a public domain + * when our scheme is http — caller should surface as 'invalid target'). + * + * Stale-scheme edge case: if a stored peer row points at the wrong scheme + * (peer migrated http↔https since the row was written), ensurePeered will + * surface a connectivity failure via the standard 'unreachable' path. Scheme + * migration of an existing peer is an admin operation outside this code's + * scope (delete + re-peer). + */ +export function resolveOriginFromHostname(hostnameOrHostPort: string): string | null { + if (!hostnameOrHostPort) return null; + const target = hostnameOrHostPort.trim().toLowerCase(); + if (!target) return null; + + const db = getDb(); + const peers = db + .select({ origin: schema.federationPeers.origin }) + .from(schema.federationPeers) + .all(); + + for (const p of peers) { + try { + const u = new URL(p.origin); + if (u.host.toLowerCase() === target) return p.origin; + } catch { + // skip malformed origin + } + } + + const ourScheme = getOurOrigin().startsWith('https://') ? 'https://' : 'http://'; + const candidate = `${ourScheme}${target}`; + return validateOrigin(candidate); +}