diff --git a/.env.example b/.env.example
index 711a195..c37735c 100644
--- a/.env.example
+++ b/.env.example
@@ -33,3 +33,9 @@ SMTP_FROM=noreply@example.com
# App URL (used for verification links, auto-detected if not set)
# APP_URL=https://your-domain.com
+
+# Federation (inter-instance meeting invitations)
+# Set both values to enable federation between Redlight instances
+# FEDERATION_DOMAIN=redlight.example.com
+# RSA Private Key for signing outbound invitations (automatically generated if missing on startup)
+# FEDERATION_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgk...\n-----END PRIVATE KEY-----"
diff --git a/server/config/database.js b/server/config/database.js
index 379427e..a326b8f 100644
--- a/server/config/database.js
+++ b/server/config/database.js
@@ -179,6 +179,20 @@ export async function initDatabase() {
value TEXT,
updated_at TIMESTAMP DEFAULT NOW()
);
+
+ CREATE TABLE IF NOT EXISTS federation_invitations (
+ id SERIAL PRIMARY KEY,
+ invite_id TEXT UNIQUE NOT NULL,
+ from_user TEXT NOT NULL,
+ to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ room_name TEXT NOT NULL,
+ message TEXT,
+ join_url TEXT NOT NULL,
+ status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
+ created_at TIMESTAMP DEFAULT NOW()
+ );
+ CREATE INDEX IF NOT EXISTS idx_fed_inv_to_user ON federation_invitations(to_user_id);
+ CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_id);
`);
} else {
await db.exec(`
@@ -240,6 +254,22 @@ export async function initDatabase() {
value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
+
+ CREATE TABLE IF NOT EXISTS federation_invitations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ invite_id TEXT UNIQUE NOT NULL,
+ from_user TEXT NOT NULL,
+ to_user_id INTEGER NOT NULL,
+ room_name TEXT NOT NULL,
+ message TEXT,
+ join_url TEXT NOT NULL,
+ status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_fed_inv_to_user ON federation_invitations(to_user_id);
+ CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_id);
`);
}
diff --git a/server/config/federation.js b/server/config/federation.js
new file mode 100644
index 0000000..b6b6cdd
--- /dev/null
+++ b/server/config/federation.js
@@ -0,0 +1,158 @@
+import crypto from 'crypto';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const FEDERATION_DOMAIN = process.env.FEDERATION_DOMAIN || '';
+let privateKeyPem = process.env.FEDERATION_PRIVATE_KEY || '';
+let publicKeyPem = '';
+
+// Load or generate RSA keys
+if (FEDERATION_DOMAIN) {
+ const keyPath = path.join(__dirname, 'federation_key.pem');
+
+ if (!privateKeyPem && fs.existsSync(keyPath)) {
+ privateKeyPem = fs.readFileSync(keyPath, 'utf8');
+ }
+
+ if (!privateKeyPem) {
+ console.log('Generating new RSA federation key pair...');
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
+ modulusLength: 2048,
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+ });
+ privateKeyPem = privateKey;
+ fs.writeFileSync(keyPath, privateKeyPem, 'utf8');
+ console.log(`Saved new federation private key to ${keyPath}`);
+ }
+
+ // Derive public key from the loaded private key
+ const currentPrivateKey = crypto.createPrivateKey(privateKeyPem);
+ publicKeyPem = crypto.createPublicKey(currentPrivateKey).export({ type: 'spki', format: 'pem' });
+}
+
+// Instance discovery cache (domain → { baseUrl, publicKey })
+const discoveryCache = new Map();
+
+/**
+ * Get this instance's federation domain.
+ */
+export function getFederationDomain() {
+ return FEDERATION_DOMAIN;
+}
+
+/**
+ * Get this instance's RSA public key (PEM format).
+ */
+export function getPublicKey() {
+ return publicKeyPem;
+}
+
+/**
+ * Check if federation is configured on this instance.
+ */
+export function isFederationEnabled() {
+ return !!(FEDERATION_DOMAIN && privateKeyPem);
+}
+
+/**
+ * RSA sign a JSON payload.
+ * @param {object} payload
+ * @returns {string} base64 signature
+ */
+export function signPayload(payload) {
+ if (!privateKeyPem) throw new Error("Federation private key not available");
+ const data = Buffer.from(JSON.stringify(payload));
+ const sign = crypto.createSign('SHA256');
+ sign.update(data);
+ sign.end();
+ return sign.sign(privateKeyPem, 'base64');
+}
+
+/**
+ * Verify an RSA signature against a JSON payload using a remote public key.
+ * @param {object} payload
+ * @param {string} signature base64 signature
+ * @param {string} remotePublicKeyPem
+ * @returns {boolean}
+ */
+export function verifyPayload(payload, signature, remotePublicKeyPem) {
+ if (!remotePublicKeyPem || !signature) return false;
+ try {
+ const data = Buffer.from(JSON.stringify(payload));
+ const verify = crypto.createVerify('SHA256');
+ verify.update(data);
+ verify.end();
+ return verify.verify(remotePublicKeyPem, signature, 'base64');
+ } catch (e) {
+ console.error('Signature verification error:', e.message);
+ return false;
+ }
+}
+
+/**
+ * Discover a remote Redlight instance's federation API base URL.
+ * Fetches https://{domain}/.well-known/redlight and caches the result.
+ * @param {string} domain
+ * @returns {Promise<{ baseUrl: string, publicKey: string }>}
+ */
+export async function discoverInstance(domain) {
+ if (discoveryCache.has(domain)) {
+ return discoveryCache.get(domain);
+ }
+
+ const wellKnownUrl = `https://${domain}/.well-known/redlight`;
+ try {
+ // Since we test locally, allow http fallback if the request fails (optional but good for testing)
+ let response;
+ try {
+ response = await fetch(wellKnownUrl);
+ } catch (e) {
+ if (e.message.includes('fetch') && wellKnownUrl.startsWith('https://localhost')) {
+ response = await fetch(`http://${domain}/.well-known/redlight`);
+ } else throw e;
+ }
+
+ if (!response.ok) {
+ throw new Error(`Discovery failed: HTTP ${response.status}`);
+ }
+ const data = await response.json();
+
+ if (!data.public_key) {
+ throw new Error(`Remote instance at ${domain} did not provide a public key`);
+ }
+
+ const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`;
+ // Optionally handle local testing gracefully for baseUrl
+ const result = {
+ baseUrl: baseUrl.replace('https://localhost', 'http://localhost'),
+ publicKey: data.public_key
+ };
+
+ discoveryCache.set(domain, result);
+ return result;
+ } catch (error) {
+ console.error(`Federation discovery failed for ${domain}:`, error.message);
+ throw new Error(`Could not discover Redlight instance at ${domain}`);
+ }
+}
+
+/**
+ * Parse a federated address like "username@domain.com".
+ * @param {string} address
+ * @returns {{ username: string, domain: string | null }}
+ */
+export function parseAddress(address) {
+ if (!address || !address.includes('@')) {
+ return { username: address, domain: null };
+ }
+ const atIndex = address.lastIndexOf('@');
+ return {
+ username: address.substring(0, atIndex),
+ domain: address.substring(atIndex + 1),
+ };
+}
diff --git a/server/index.js b/server/index.js
index 6eeb2f7..4f70ed0 100644
--- a/server/index.js
+++ b/server/index.js
@@ -10,6 +10,7 @@ import roomRoutes from './routes/rooms.js';
import recordingRoutes from './routes/recordings.js';
import adminRoutes from './routes/admin.js';
import brandingRoutes from './routes/branding.js';
+import federationRoutes, { wellKnownHandler } from './routes/federation.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -35,6 +36,8 @@ async function start() {
app.use('/api/recordings', recordingRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/branding', brandingRoutes);
+ app.use('/api/federation', federationRoutes);
+ app.get('/.well-known/redlight', wellKnownHandler);
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
diff --git a/server/routes/federation.js b/server/routes/federation.js
new file mode 100644
index 0000000..40fa1d3
--- /dev/null
+++ b/server/routes/federation.js
@@ -0,0 +1,262 @@
+import { Router } from 'express';
+import { v4 as uuidv4 } from 'uuid';
+import { getDb } from '../config/database.js';
+import { authenticateToken } from '../middleware/auth.js';
+import {
+ getFederationDomain,
+ isFederationEnabled,
+ getPublicKey,
+ signPayload,
+ verifyPayload,
+ discoverInstance,
+ parseAddress,
+} from '../config/federation.js';
+
+const router = Router();
+
+// ── Well-known discovery endpoint ───────────────────────────────────────────
+// Mounted at /.well-known/redlight in index.js
+export function wellKnownHandler(req, res) {
+ const domain = getFederationDomain();
+ if (!domain) {
+ return res.status(404).json({ error: 'Federation not configured' });
+ }
+ res.json({
+ domain,
+ federation_api: '/api/federation',
+ public_key: getPublicKey(),
+ software: 'Redlight',
+ version: '1.1.0',
+ });
+}
+
+// ── POST /api/federation/invite — Send invitation to remote user ────────────
+router.post('/invite', authenticateToken, async (req, res) => {
+ try {
+ if (!isFederationEnabled()) {
+ return res.status(400).json({ error: 'Federation is not configured on this instance' });
+ }
+
+ const { room_uid, to, message } = req.body;
+ if (!room_uid || !to) {
+ return res.status(400).json({ error: 'room_uid and to are required' });
+ }
+
+ const { username, domain } = parseAddress(to);
+ if (!domain) {
+ return res.status(400).json({ error: 'Remote address must be in format username@domain' });
+ }
+
+ // Don't allow inviting to own instance
+ if (domain === getFederationDomain()) {
+ return res.status(400).json({ error: 'Cannot send federation invite to your own instance. Use local sharing instead.' });
+ }
+
+ const db = getDb();
+
+ // Verify room exists and user has access
+ const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [room_uid]);
+ if (!room) {
+ return res.status(404).json({ error: 'Room not found' });
+ }
+
+ const isOwner = room.user_id === req.user.id;
+ if (!isOwner) {
+ const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
+ if (!share) {
+ return res.status(403).json({ error: 'No permission to invite from this room' });
+ }
+ }
+
+ // Build guest join URL for the remote user
+ const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
+ const joinUrl = `${baseUrl}/join/${room.uid}`;
+
+ // Build invitation payload
+ const inviteId = uuidv4();
+ const payload = {
+ invite_id: inviteId,
+ from_user: `${req.user.name}@${getFederationDomain()}`,
+ to_user: to,
+ room_name: room.name,
+ message: message || null,
+ join_url: joinUrl,
+ timestamp: new Date().toISOString(),
+ };
+
+ // Sign and send to remote instance
+ const signature = signPayload(payload);
+ const { baseUrl: remoteApi } = await discoverInstance(domain);
+
+ const response = await fetch(`${remoteApi}/receive`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Federation-Signature': signature,
+ 'X-Federation-Origin': getFederationDomain(),
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}));
+ throw new Error(data.error || `Remote server responded with ${response.status}`);
+ }
+
+ res.json({ success: true, invite_id: inviteId });
+ } catch (err) {
+ console.error('Federation invite error:', err);
+ res.status(500).json({ error: err.message || 'Failed to send federation invite' });
+ }
+});
+
+// ── POST /api/federation/receive — Accept incoming invitation from remote ───
+router.post('/receive', async (req, res) => {
+ try {
+ if (!isFederationEnabled()) {
+ return res.status(400).json({ error: 'Federation is not configured on this instance' });
+ }
+
+ const signature = req.headers['x-federation-signature'];
+ const payload = req.body;
+
+ if (!signature) {
+ return res.status(401).json({ error: 'Missing federation signature' });
+ }
+
+ if (!invite_id || !from_user || !to_user || !room_name || !join_url) {
+ return res.status(400).json({ error: 'Incomplete invitation payload' });
+ }
+
+ // Fetch the sender's public key dynamically
+ const { domain: senderDomain } = parseAddress(from_user);
+ if (!senderDomain) {
+ return res.status(400).json({ error: 'Sender address must include a domain' });
+ }
+
+ const { publicKey } = await discoverInstance(senderDomain);
+ if (!publicKey) {
+ return res.status(400).json({ error: 'Sender instance did not provide a public key' });
+ }
+
+ if (!verifyPayload(payload, signature, publicKey)) {
+ return res.status(403).json({ error: 'Invalid federation RSA signature' });
+ }
+
+ // Parse the target address and find local user
+ const { username } = parseAddress(to_user);
+ const db = getDb();
+
+ // Look up user by name (case-insensitive)
+ const targetUser = await db.get(
+ 'SELECT id FROM users WHERE LOWER(name) = LOWER(?)',
+ [username]
+ );
+
+ if (!targetUser) {
+ return res.status(404).json({ error: 'User not found on this instance' });
+ }
+
+ // Check for duplicate
+ const existing = await db.get(
+ 'SELECT id FROM federation_invitations WHERE invite_id = ?',
+ [invite_id]
+ );
+ if (existing) {
+ return res.json({ success: true, message: 'Invitation already received' });
+ }
+
+ // Store the invitation
+ await db.run(
+ `INSERT INTO federation_invitations (invite_id, from_user, to_user_id, room_name, message, join_url)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [invite_id, from_user, targetUser.id, room_name, message || null, join_url]
+ );
+
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Federation receive error:', err);
+ res.status(500).json({ error: 'Failed to process federation invitation' });
+ }
+});
+
+// ── GET /api/federation/invitations — List invitations for current user ─────
+router.get('/invitations', authenticateToken, async (req, res) => {
+ try {
+ const db = getDb();
+ const invitations = await db.all(
+ `SELECT * FROM federation_invitations
+ WHERE to_user_id = ?
+ ORDER BY created_at DESC`,
+ [req.user.id]
+ );
+ res.json({ invitations });
+ } catch (err) {
+ console.error('List federation invitations error:', err);
+ res.status(500).json({ error: 'Failed to load invitations' });
+ }
+});
+
+// ── GET /api/federation/invitations/pending-count — Badge count ─────────────
+router.get('/invitations/pending-count', authenticateToken, async (req, res) => {
+ try {
+ const db = getDb();
+ const result = await db.get(
+ `SELECT COUNT(*) as count FROM federation_invitations
+ WHERE to_user_id = ? AND status = 'pending'`,
+ [req.user.id]
+ );
+ res.json({ count: result?.count || 0 });
+ } catch (err) {
+ res.json({ count: 0 });
+ }
+});
+
+// ── POST /api/federation/invitations/:id/accept — Accept an invitation ──────
+router.post('/invitations/:id/accept', authenticateToken, async (req, res) => {
+ try {
+ const db = getDb();
+ const invitation = await db.get(
+ 'SELECT * FROM federation_invitations WHERE id = ? AND to_user_id = ?',
+ [req.params.id, req.user.id]
+ );
+
+ if (!invitation) {
+ return res.status(404).json({ error: 'Invitation not found' });
+ }
+
+ await db.run(
+ "UPDATE federation_invitations SET status = 'accepted' WHERE id = ?",
+ [invitation.id]
+ );
+
+ res.json({ success: true, join_url: invitation.join_url });
+ } catch (err) {
+ console.error('Accept invitation error:', err);
+ res.status(500).json({ error: 'Failed to accept invitation' });
+ }
+});
+
+// ── DELETE /api/federation/invitations/:id — Decline/dismiss invitation ─────
+router.delete('/invitations/:id', authenticateToken, async (req, res) => {
+ try {
+ const db = getDb();
+ const invitation = await db.get(
+ 'SELECT * FROM federation_invitations WHERE id = ? AND to_user_id = ?',
+ [req.params.id, req.user.id]
+ );
+
+ if (!invitation) {
+ return res.status(404).json({ error: 'Invitation not found' });
+ }
+
+ await db.run('DELETE FROM federation_invitations WHERE id = ?', [invitation.id]);
+
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Decline invitation error:', err);
+ res.status(500).json({ error: 'Failed to decline invitation' });
+ }
+});
+
+export default router;
diff --git a/src/App.jsx b/src/App.jsx
index 5d94aa9..92f7d15 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -14,6 +14,7 @@ import RoomDetail from './pages/RoomDetail';
import Settings from './pages/Settings';
import Admin from './pages/Admin';
import GuestJoin from './pages/GuestJoin';
+import FederationInbox from './pages/FederationInbox';
export default function App() {
const { user, loading } = useAuth();
@@ -55,6 +56,7 @@ export default function App() {
{t('federation.inboxSubtitle')}
++ {t('federation.from')}: {inv.from_user} +
+ {inv.message && ( +"{inv.message}"
+ )} ++ {new Date(inv.created_at).toLocaleString()} +
+{inv.from_user}
+{t('federation.noInvitationsSubtitle')}
+