Public-release prep: ELv2 license, README/CLA/NOTICE, SSRF safeFetch, identifier genericization, export tooling

This commit is contained in:
Jannis Braun
2026-06-22 16:04:03 +02:00
parent c0a6477059
commit 8dd76f3435
44 changed files with 1272 additions and 267 deletions
+3 -1
View File
@@ -2,9 +2,11 @@
"name": "@backspace/server",
"version": "1.0.0",
"private": true,
"license": "Elastic-2.0",
"author": "Jannis Braun",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev": "PORT=3005 tsx watch src/index.ts",
"start": "node --import tsx/esm src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
@@ -52,11 +52,11 @@ beforeEach(() => {
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
sentToUserCalls.length = 0;
// Local user (youruser) and replicated stub (pbtest3) — they're friends.
// Local user (erin) and replicated stub (pbtest3) — they're friends.
testDb.insert(schema.users).values([
{
id: 'local-youruser', username: 'youruser', passwordHash: 'x', status: 'online', isAdmin: 0,
homeUserId: 'local-youruser', createdAt: Date.now(),
id: 'local-erin', username: 'erin', passwordHash: 'x', status: 'online', isAdmin: 0,
homeUserId: 'local-erin', createdAt: Date.now(),
},
{
id: 'stub-pbtest3', username: 'pbtest3@orbit.ddns.net', displayName: 'pbtest3',
@@ -65,7 +65,7 @@ beforeEach(() => {
},
]).run();
testDb.insert(schema.friends).values({
userId: 'local-youruser', friendId: 'stub-pbtest3', createdAt: Date.now(),
userId: 'local-erin', friendId: 'stub-pbtest3', createdAt: Date.now(),
}).run();
});
@@ -94,7 +94,7 @@ describe('processPresenceUpdateEvent', () => {
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-pbtest3')).get();
expect(row!.status).toBe('online');
const broadcast = sentToUserCalls.find((c) => c.userId === 'local-youruser');
const broadcast = sentToUserCalls.find((c) => c.userId === 'local-erin');
expect(broadcast).toBeDefined();
expect(broadcast!.payload.type).toBe('presence_update');
expect(broadcast!.payload.userId).toBe('stub-pbtest3');
@@ -160,7 +160,7 @@ describe('processPresenceUpdateEvent', () => {
},
};
fed.processPresenceUpdateEvent(event, 'orbit.ddns.net', testDb, [], []);
const broadcast = sentToUserCalls.find((c) => c.userId === 'local-youruser');
const broadcast = sentToUserCalls.find((c) => c.userId === 'local-erin');
expect(broadcast!.payload.activities).toEqual([{ type: 'playing', name: 'Test' }]);
});
});
+2 -2
View File
@@ -2566,7 +2566,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
// 4. Query mutation log for the relevant channels
// Return mutations for ALL locally-created messages (source_instance IS NULL).
// This includes messages by replicated users (e.g., Jannis browsing orbit)
// This includes messages by replicated users (e.g., Heidi browsing orbit)
// because they were created on THIS instance and need to be synced to the peer.
if (effectiveChannelFilter) {
// Validate that the requested channel is actually shared with this peer
@@ -3111,7 +3111,7 @@ export function extractDomain(homeInstance: string): string {
* Two valid cases:
* 1. **Direct**: author is from the source instance (standard S2S — peer sends events for its own users).
* 2. **Homeward relay**: author is from the *receiving* instance. This happens when a client-federation
* user (e.g., youruser@nova logged into orbit) sends a message on a remote server, and the
* user (e.g., erin@nova logged into orbit) sends a message on a remote server, and the
* S2S relay forwards it back to the author's home instance. The trusted peer is just the messenger.
*
* Both sides are normalized to bare domain before comparison.
@@ -0,0 +1,243 @@
/**
* One-time backfill: populate width/height for image embeds that lack them,
* and create image embeds for old bare-image-URL messages that never got one
* (these predate the `resolveEmbeds` call site in routes/messages.ts).
*
* Idempotent — re-running skips already-dim'd rows and already-embedded
* messages via the WHERE clauses.
*
* Run from the running container:
* docker exec backspace node dist/scripts/backfill-image-embed-dims.js
*/
import fs from 'fs';
import path from 'path';
import { initDatabase, getRawDb, schema } from '../db/index.js';
import { probeRemoteImageDimensions } from '../utils/embedResolver.js';
import { extractUrls } from '../utils/embedResolver.js';
import { classifyUrl } from '../utils/embedClassifier.js';
import { probeImageDimensions } from '../utils/thumbnail.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { config } from '../config.js';
const CONCURRENCY = 4;
interface PendingProbe {
kind: 'update-embed' | 'create-embed-for-message' | 'create-embed-for-dm-message';
url: string;
embedId?: string;
messageId?: string;
dmMessageId?: string;
}
async function withConcurrency<T>(items: T[], n: number, worker: (item: T) => Promise<void>): Promise<void> {
let i = 0;
const runners = Array.from({ length: Math.min(n, items.length) }, async () => {
while (true) {
const idx = i++;
if (idx >= items.length) return;
const item = items[idx];
if (item === undefined) return;
await worker(item);
}
});
await Promise.all(runners);
}
function isLikelyImageUrl(url: string): boolean {
return classifyUrl(url).embedType === 'image';
}
async function main(): Promise<void> {
// initDatabase() runs migrations + ensureDefaults + sets the snowflake
// worker ID from instance_settings.worker_id. We rely on that for
// generateSnowflake() below.
initDatabase();
const db = getRawDb();
// ── Pass 1: image embeds missing dimensions ──────────────────────────────
const nullDimEmbeds = db.prepare(
`SELECT id, url FROM embeds WHERE embed_type = 'image' AND (width IS NULL OR height IS NULL)`,
).all() as Array<{ id: string; url: string }>;
console.log(`[backfill] Found ${nullDimEmbeds.length} image embeds missing width/height`);
// ── Pass 2: messages with bare image URLs but no embed row ───────────────
// SQLite has no REGEXP by default; use LIKE with the two known providers
// plus a generic suffix match. We re-classify each candidate URL with
// classifyUrl() before probing so non-image extensions are filtered out.
const candidateMessages = db.prepare(
`SELECT m.id, m.content
FROM messages m
LEFT JOIN embeds e ON e.message_id = m.id
WHERE m.content IS NOT NULL
AND (
m.content LIKE 'https://media.tenor.com/%'
OR m.content LIKE 'https://static.klipy.com/%'
OR m.content LIKE '%.gif'
OR m.content LIKE '%.webp'
)
AND e.id IS NULL`,
).all() as Array<{ id: string; content: string }>;
console.log(`[backfill] Found ${candidateMessages.length} channel messages with image URLs and no embed`);
const candidateDmMessages = db.prepare(
`SELECT m.id, m.content
FROM dm_messages m
LEFT JOIN embeds e ON e.dm_message_id = m.id
WHERE m.content IS NOT NULL
AND (
m.content LIKE 'https://media.tenor.com/%'
OR m.content LIKE 'https://static.klipy.com/%'
OR m.content LIKE '%.gif'
OR m.content LIKE '%.webp'
)
AND e.id IS NULL`,
).all() as Array<{ id: string; content: string }>;
console.log(`[backfill] Found ${candidateDmMessages.length} DM messages with image URLs and no embed`);
// ── Build work queue ─────────────────────────────────────────────────────
const work: PendingProbe[] = [];
for (const row of nullDimEmbeds) {
work.push({ kind: 'update-embed', url: row.url, embedId: row.id });
}
for (const m of candidateMessages) {
const urls = extractUrls(m.content);
for (const url of urls) {
if (isLikelyImageUrl(url)) {
work.push({ kind: 'create-embed-for-message', url, messageId: m.id });
}
}
}
for (const m of candidateDmMessages) {
const urls = extractUrls(m.content);
for (const url of urls) {
if (isLikelyImageUrl(url)) {
work.push({ kind: 'create-embed-for-dm-message', url, dmMessageId: m.id });
}
}
}
// ── Pass 3: image attachments missing dimensions ─────────────────────────
// Probe local files directly (the upload sits on the same filesystem). The
// `failOn: 'none'` flag in `probeImageDimensions` handles animated GIFs
// whose default sharp probe was failing on frame-data validation.
const nullDimAttachments = db.prepare(
`SELECT id, filename FROM attachments WHERE mimetype LIKE 'image/%' AND (width IS NULL OR height IS NULL)`,
).all() as Array<{ id: string; filename: string }>;
console.log(`[backfill] Found ${nullDimAttachments.length} image attachments missing width/height`);
const updateAttachment = db.prepare(`UPDATE attachments SET width = ?, height = ? WHERE id = ?`);
let attachmentSuccesses = 0;
let attachmentSkipped = 0;
for (const a of nullDimAttachments) {
const filepath = path.join(config.uploadDir, a.filename);
if (!fs.existsSync(filepath)) {
// Federated remote attachments live on the source instance, not here.
// Their dims need to be backfilled from the source side, or via the
// federation file-replication path. Skip silently.
attachmentSkipped++;
continue;
}
const dims = await probeImageDimensions(filepath);
if (dims) {
updateAttachment.run(dims.width, dims.height, a.id);
attachmentSuccesses++;
}
}
console.log(`[backfill] attachments updated=${attachmentSuccesses}, skipped (file not local)=${attachmentSkipped}`);
// ── Pass 4: image embeds whose URL points at our local uploads ───────────
// SSRF correctly blocks the remote probe from hitting our own host. For
// these specific embeds we have the file locally — extract the upload
// filename from the URL path (`/api/uploads/<filename>`) and probe it
// directly, sidestepping HTTP entirely. Federated `/api/uploads/...` URLs
// on a different host won't have a local file and are skipped.
const localUploadEmbeds = db.prepare(
`SELECT id, url FROM embeds WHERE embed_type = 'image' AND (width IS NULL OR height IS NULL) AND url LIKE '%/api/uploads/%'`,
).all() as Array<{ id: string; url: string }>;
console.log(`[backfill] Found ${localUploadEmbeds.length} image embeds pointing at /api/uploads`);
const updateEmbedDims = db.prepare(`UPDATE embeds SET width = ?, height = ? WHERE id = ?`);
let localEmbedSuccesses = 0;
let localEmbedSkipped = 0;
for (const e of localUploadEmbeds) {
const match = e.url.match(/\/api\/uploads\/([^/?#]+)/);
if (!match || !match[1]) { localEmbedSkipped++; continue; }
const filepath = path.join(config.uploadDir, match[1]);
if (!fs.existsSync(filepath)) { localEmbedSkipped++; continue; }
const dims = await probeImageDimensions(filepath);
if (dims) {
updateEmbedDims.run(dims.width, dims.height, e.id);
localEmbedSuccesses++;
}
}
console.log(`[backfill] local-upload embeds updated=${localEmbedSuccesses}, skipped (no local file)=${localEmbedSkipped}`);
console.log(`[backfill] ${work.length} remote probes to run (concurrency=${CONCURRENCY})`);
// ── Execute ──────────────────────────────────────────────────────────────
const updateEmbed = db.prepare(
`UPDATE embeds SET width = ?, height = ? WHERE id = ?`,
);
const insertEmbed = db.prepare(
`INSERT INTO embeds (id, message_id, dm_message_id, url, embed_type, provider, title, description, image, embed_url, width, height, color, created_at)
VALUES (?, ?, ?, ?, 'image', NULL, NULL, NULL, ?, NULL, ?, ?, NULL, ?)`,
);
let successes = 0;
let probeFailures = 0;
let progress = 0;
await withConcurrency(work, CONCURRENCY, async (item) => {
const dims = await probeRemoteImageDimensions(item.url);
progress++;
if (progress % 25 === 0) {
console.log(`[backfill] progress ${progress}/${work.length} (ok=${successes}, fail=${probeFailures})`);
}
if (!dims) {
probeFailures++;
return;
}
if (item.kind === 'update-embed' && item.embedId) {
updateEmbed.run(dims.width, dims.height, item.embedId);
successes++;
} else if (item.kind === 'create-embed-for-message' && item.messageId) {
insertEmbed.run(
generateSnowflake(),
item.messageId,
null,
item.url,
item.url,
dims.width,
dims.height,
Date.now(),
);
successes++;
} else if (item.kind === 'create-embed-for-dm-message' && item.dmMessageId) {
insertEmbed.run(
generateSnowflake(),
null,
item.dmMessageId,
item.url,
item.url,
dims.width,
dims.height,
Date.now(),
);
successes++;
}
});
console.log(`[backfill] done. successes=${successes}, probe-failures=${probeFailures}, total=${work.length}`);
// Silence the schema import linter — it's intentionally available for
// future per-table tweaks without re-importing.
void schema;
process.exit(0);
}
main().catch((err) => {
console.error('[backfill] fatal:', err);
process.exit(1);
});
+3 -10
View File
@@ -5,7 +5,7 @@ import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from './snowflake.js';
import { classifyUrl } from './embedClassifier.js';
import { fetchUrlMetadata } from './metadataFetcher.js';
import { validateExternalUrl } from './ssrf.js';
import { safeFetch } from './ssrf.js';
import { connectionManager } from '../ws/handler.js';
const MAX_EMBEDS_PER_MESSAGE = 5;
@@ -18,27 +18,20 @@ const PROBE_TIMEOUT_MS = 3_000;
* Uses Range request to avoid downloading the entire file.
* Returns null on any failure (timeout, network, unrecognized format, SSRF block).
*/
async function probeRemoteImageDimensions(
export async function probeRemoteImageDimensions(
url: string,
): Promise<{ width: number; height: number } | null> {
try {
await validateExternalUrl(url);
} catch {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
try {
const response = await fetch(url, {
const response = await safeFetch(url, {
headers: {
'User-Agent': 'BackspaceBot/1.0',
Accept: 'image/*',
Range: `bytes=0-${PROBE_BYTES - 1}`,
},
signal: controller.signal,
redirect: 'follow',
});
// NOTE: Do NOT clearTimeout here — keep the abort active during body read.
// The finally block handles cleanup after all reads complete.
@@ -73,7 +73,7 @@ beforeEach(() => {
}).run();
// Online native FRIENDED with the stub — should be snapshotted
testDb.insert(schema.users).values({
id: 'native-friend', username: 'youruser', passwordHash: 'x',
id: 'native-friend', username: 'erin', passwordHash: 'x',
status: 'online', isAdmin: 0, homeUserId: 'native-friend', createdAt: Date.now(),
}).run();
testDb.insert(schema.friends).values({
@@ -62,7 +62,7 @@ beforeEach(() => {
// Native local user
testDb.insert(schema.users).values({
id: 'native-1',
username: 'youruser',
username: 'erin',
passwordHash: 'x',
status: 'online',
isAdmin: 0,
+2 -9
View File
@@ -1,5 +1,5 @@
import * as cheerio from 'cheerio';
import { validateExternalUrl } from './ssrf.js';
import { safeFetch } from './ssrf.js';
export interface UrlMetadata {
title: string | null;
@@ -16,21 +16,14 @@ export interface UrlMetadata {
}
export async function fetchUrlMetadata(url: string): Promise<UrlMetadata | null> {
try {
await validateExternalUrl(url);
} catch {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(url, {
const response = await safeFetch(url, {
headers: {
'User-Agent': 'BackspaceBot/1.0',
},
signal: controller.signal,
redirect: 'follow',
});
clearTimeout(timeout);
@@ -22,11 +22,18 @@ vi.mock('../db/index.js', () => ({
}));
// Mock the ssrf module so tests don't need real DNS resolution.
// Default: validateExternalUrl resolves (allow). Individual tests override as needed.
vi.mock('./ssrf.js', () => ({
validateExternalUrl: vi.fn().mockResolvedValue(undefined),
isPrivateIp: vi.fn().mockReturnValue(false),
}));
// safeFetch mirrors the real contract: validate the URL first, then fetch — so a
// rejecting validator must prevent the fetch. Default: validateExternalUrl
// resolves (allow). Individual tests override via mockRejectedValueOnce.
vi.mock('./ssrf.js', () => {
const validateExternalUrl = vi.fn().mockResolvedValue(undefined);
const isPrivateIp = vi.fn().mockReturnValue(false);
const safeFetch = vi.fn(async (url: string, init?: RequestInit) => {
await validateExternalUrl(url);
return (global.fetch as unknown as typeof fetch)(url, init);
});
return { validateExternalUrl, isPrivateIp, safeFetch };
});
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
@@ -95,16 +102,14 @@ describe('fetchSpaceInviteSnapshot', () => {
});
it('returns null when SSRF validator rejects the origin', async () => {
// Override the module-level mock to reject for this test only.
// Reject validation for this one call; safeFetch must bail before fetching.
const ssrf = await import('./ssrf.js');
const spy = vi.spyOn(ssrf, 'validateExternalUrl').mockRejectedValueOnce(new Error('blocked'));
(ssrf.validateExternalUrl as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('blocked'));
const fetchSpy = global.fetch as any;
const snap = await fetchSpaceInviteSnapshot('http://127.0.0.1:9200', 'abc');
expect(snap).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled(); // CRITICAL — the fetch must NOT happen
spy.mockRestore();
});
});
@@ -1,7 +1,7 @@
import type { AvatarColor } from '@backspace/shared';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { validateExternalUrl } from './ssrf.js';
import { safeFetch } from './ssrf.js';
export interface SpaceInviteSnapshot {
spaceId: string;
@@ -59,15 +59,10 @@ export async function fetchSpaceInviteSnapshot(
timeoutMs = 5000,
): Promise<SpaceInviteSnapshot | null> {
const url = `${spaceInstanceOrigin}/api/spaces/invite/${encodeURIComponent(inviteCode)}/preview`;
try {
await validateExternalUrl(url);
} catch {
return null;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
const res = await safeFetch(url, { signal: controller.signal });
if (!res.ok) return null;
const data = await res.json() as Partial<SpaceInviteSnapshot>;
if (typeof data?.spaceId !== 'string' || typeof data?.spaceName !== 'string') return null;
+35
View File
@@ -43,3 +43,38 @@ export async function validateExternalUrl(url: string): Promise<void> {
throw new Error('Private IP not allowed');
}
}
const MAX_REDIRECTS = 5;
/**
* SSRF-safe fetch. Validates the target URL and re-validates the destination of
* every redirect hop before following it, so a hostile server cannot 30x-redirect
* an outbound request to an internal address (loopback, link-local, RFC1918).
*
* Use this instead of bare `fetch()` for any request to a user- or peer-supplied
* URL. Redirects are followed manually (Node/undici exposes the 3xx + Location
* with `redirect: 'manual'`), capped at MAX_REDIRECTS.
*
* Residual: validateExternalUrl resolves DNS, then fetch resolves again — a
* narrow DNS-rebinding TOCTOU window remains. Pinning the resolved IP at connect
* time would close it but requires a custom dispatcher; the redirect re-check
* here closes the practical, attacker-controlled bypass.
*/
export async function safeFetch(url: string, init: RequestInit = {}): Promise<Response> {
let currentUrl = url;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
await validateExternalUrl(currentUrl);
const response = await fetch(currentUrl, { ...init, redirect: 'manual' });
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) return response; // 3xx without a target — hand back as-is
// Resolve relative redirects against the current URL, then loop to re-validate.
currentUrl = new URL(location, currentUrl).toString();
continue;
}
return response;
}
throw new Error('Too many redirects');
}
+51 -25
View File
@@ -17,9 +17,32 @@ import { AccessToken, RoomServiceClient } from 'livekit-server-sdk';
const __dirname = dirname(fileURLToPath(import.meta.url));
dotenvConfig({ path: resolve(__dirname, '../../.env') });
const LIVEKIT_URL = process.env.LIVEKIT_URL || 'wss://nova.ddns.net/livekit';
const API_KEY = process.env.LIVEKIT_API_KEY || 'REDACTED_LIVEKIT_KEY';
const API_SECRET = process.env.LIVEKIT_API_SECRET || 'REDACTED_LIVEKIT_SECRET';
// All values come from the environment (loaded from .env above). Never hardcode
// credentials, hostnames, or LAN addresses here — this script ships in the repo.
const env = {
url: process.env.LIVEKIT_URL,
apiKey: process.env.LIVEKIT_API_KEY,
apiSecret: process.env.LIVEKIT_API_SECRET,
};
const missing = (['url', 'apiKey', 'apiSecret'] as const)
.filter((k) => !env[k])
.map((k) => ({ url: 'LIVEKIT_URL', apiKey: 'LIVEKIT_API_KEY', apiSecret: 'LIVEKIT_API_SECRET' }[k]));
if (missing.length > 0) {
console.error('LIVEKIT VERIFICATION FAILED');
console.error(`Missing required environment variable(s): ${missing.join(', ')}`);
console.error('Set them in packages/server/.env (or the process environment) and re-run.');
process.exit(1);
}
// Narrowed to string: guaranteed present past the guard above.
const LIVEKIT_URL = env.url as string;
const API_KEY = env.apiKey as string;
const API_SECRET = env.apiSecret as string;
// Optional: a LAN-local LiveKit URL to try first (e.g. http://10.0.0.5:7880),
// useful when the public domain does not hairpin on the local network.
const LIVEKIT_LAN_URL = process.env.LIVEKIT_LAN_URL;
async function verify() {
console.log('=== LiveKit Verification ===');
@@ -66,32 +89,35 @@ async function verify() {
// Step 3: Connect to LiveKit server via RoomServiceClient
console.log('[3/3] Connecting to LiveKit server...');
// The LiveKit server runs on the Pi at 192.168.1.10:7880 (host network mode).
// The DDNS domain (nova.ddns.net) routes externally but may not loop back on LAN.
// Try the LAN address first, then fall back to the configured URL.
const lanUrl = 'http://192.168.1.10:7880';
// Convert the configured ws(s):// URL to its http(s):// form for the REST client.
const wanUrl = LIVEKIT_URL.replace('wss://', 'https://').replace('ws://', 'http://');
// Optionally try a LAN-local address first (set LIVEKIT_LAN_URL) — useful when
// the public domain does not hairpin back to the host on the local network.
const candidates = [LIVEKIT_LAN_URL, wanUrl].filter((u): u is string => Boolean(u));
let roomService: RoomServiceClient;
let usedUrl: string;
try {
roomService = new RoomServiceClient(lanUrl, API_KEY, API_SECRET);
const rooms = await roomService.listRooms();
usedUrl = lanUrl;
console.log(` Server responded via LAN (${lanUrl}). Active rooms: ${rooms.length}`);
for (const room of rooms) {
console.log(` - ${room.name} (${room.numParticipants} participants)`);
}
} catch {
console.log(` LAN address unreachable, trying WAN (${wanUrl})...`);
roomService = new RoomServiceClient(wanUrl, API_KEY, API_SECRET);
const rooms = await roomService.listRooms();
usedUrl = wanUrl;
console.log(` Server responded via WAN (${wanUrl}). Active rooms: ${rooms.length}`);
for (const room of rooms) {
console.log(` - ${room.name} (${room.numParticipants} participants)`);
let usedUrl = '';
let lastError: unknown;
for (const candidate of candidates) {
try {
const roomService = new RoomServiceClient(candidate, API_KEY, API_SECRET);
const rooms = await roomService.listRooms();
usedUrl = candidate;
console.log(` Server responded via ${candidate}. Active rooms: ${rooms.length}`);
for (const room of rooms) {
console.log(` - ${room.name} (${room.numParticipants} participants)`);
}
break;
} catch (err) {
lastError = err;
console.log(` ${candidate} unreachable${candidates.length > 1 ? ', trying next…' : ''}`);
}
}
if (!usedUrl) {
throw new Error(
`Could not reach the LiveKit server at any of: ${candidates.join(', ')}`,
{ cause: lastError },
);
}
console.log(` ✓ LiveKit server is reachable at ${usedUrl} and credentials are valid`);
console.log('');