Add optional Cloudflare TURN credentials and secure profile sharing

This commit is contained in:
vorotamoroz
2026-09-15 16:20:03 +00:00
parent ba297d1233
commit 93bc161f20
40 changed files with 1855 additions and 182 deletions
@@ -0,0 +1,384 @@
import { IceServerSourceError } from "@vrtmrz/livesync-commonlib/p2p";
import type { IceServerConfiguration, IceServerSource } from "@vrtmrz/livesync-commonlib/p2p";
import {
CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT,
CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS,
parseCloudflareIceServerSourceConfiguration,
type CloudflareIceServerSourceConfiguration,
validateCloudflareIceServerSourceConfiguration,
} from "./settings";
/** Fetch-compatible function supplied by the host composition. */
export type CloudflareIceServerSourceFetch = (input: string | Request, init?: RequestInit) => Promise<Response>;
export interface CloudflareIceServerSourceDependencies {
readonly fetch: CloudflareIceServerSourceFetch;
readonly now?: () => number;
readonly requestDeadlineMs?: number;
}
export const CLOUDFLARE_TURN_REQUEST_DEADLINE_MS = 15_000 as const;
export const CLOUDFLARE_TURN_MAX_RESPONSE_BYTES = 32 * 1024;
export const CLOUDFLARE_TURN_MAX_ICE_SERVER_ENTRIES = 16 as const;
export const CLOUDFLARE_TURN_MAX_ICE_SERVER_URLS = 32 as const;
export const CLOUDFLARE_TURN_MIN_REMAINING_LIFETIME_MS = 1_000 as const;
type IceServerSourceFailureCode = "configuration" | "authentication" | "unavailable" | "invalid-response";
const SOURCE_FAILURE_MESSAGES: Record<IceServerSourceFailureCode, string> = {
configuration: "The Cloudflare TURN source configuration is invalid.",
authentication: "The Cloudflare TURN credential request was not authorised.",
unavailable: "The Cloudflare TURN service is unavailable.",
"invalid-response": "The Cloudflare TURN service returned an invalid response.",
};
function sourceFailure(code: IceServerSourceFailureCode, retryable: boolean): IceServerSourceError {
return new IceServerSourceError(code, SOURCE_FAILURE_MESSAGES[code], retryable);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function abortError(): Error {
try {
return new DOMException("The operation was aborted.", "AbortError");
} catch {
const error = new Error("The operation was aborted.");
error.name = "AbortError";
return error;
}
}
function throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw abortError();
}
}
function isControlCharacter(value: string): boolean {
return Array.from(value).some((character) => {
const code = character.charCodeAt(0);
return code <= 0x1f || code === 0x7f;
});
}
function isPort(value: string): boolean {
if (!/^\d{1,5}$/.test(value)) return false;
const port = Number(value);
return port >= 1 && port <= 65_535;
}
function isHost(value: string): boolean {
return value.length > 0 && /^[A-Za-z0-9._-]+$/.test(value);
}
/**
* Validates the URL forms accepted by WebRTC's ICE server configuration.
* TURN URLs may carry only the standard transport query parameter; userinfo,
* paths, fragments, and arbitrary query values are not accepted.
*/
export function isSupportedIceServerUrl(value: string): boolean {
if (value.length === 0 || value.length > 2_048 || isControlCharacter(value)) return false;
const schemeMatch = /^(stun|stuns|turn|turns):(.+)$/i.exec(value);
if (!schemeMatch) return false;
const remainder = schemeMatch[2];
const queryIndex = remainder.indexOf("?");
const authority = queryIndex >= 0 ? remainder.slice(0, queryIndex) : remainder;
const query = queryIndex >= 0 ? remainder.slice(queryIndex + 1) : "";
if (authority.length === 0 || authority.includes("/") || authority.includes("#") || authority.includes("@")) {
return false;
}
if (authority.includes("%")) return false;
if (authority.startsWith("[")) {
const closingBracket = authority.indexOf("]");
if (closingBracket < 0) return false;
const host = authority.slice(1, closingBracket);
if (!/^[0-9A-Fa-f:.]+$/.test(host) || !host.includes(":")) return false;
const suffix = authority.slice(closingBracket + 1);
if (suffix !== "" && (!suffix.startsWith(":") || !isPort(suffix.slice(1)))) return false;
} else {
const colonIndex = authority.lastIndexOf(":");
const host = colonIndex >= 0 ? authority.slice(0, colonIndex) : authority;
if (!isHost(host) || (colonIndex >= 0 && !isPort(authority.slice(colonIndex + 1)))) return false;
// IPv6 literals must use brackets so a colon cannot be interpreted as
// an ambiguous port separator.
if (colonIndex >= 0 && host.includes(":")) return false;
}
if (query.length === 0) return true;
const queryParts = query.split("&");
return queryParts.length === 1 && /^transport=(udp|tcp)$/i.test(queryParts[0]);
}
function isTurnUrl(value: string): boolean {
return /^(turn|turns):/i.test(value);
}
function isCredential(value: unknown): value is string {
return typeof value === "string" && value.length > 0 && value.length <= 4_096 && !isControlCharacter(value);
}
function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
if (!isRecord(value) || !Array.isArray(value.iceServers)) {
throw sourceFailure("invalid-response", false);
}
if (value.iceServers.length === 0 || value.iceServers.length > CLOUDFLARE_TURN_MAX_ICE_SERVER_ENTRIES) {
throw sourceFailure("invalid-response", false);
}
const servers: RTCIceServer[] = [];
let urlCount = 0;
let hasTurnServer = false;
for (const candidate of value.iceServers) {
if (!isRecord(candidate)) throw sourceFailure("invalid-response", false);
const rawUrls = candidate.urls;
const urls =
typeof rawUrls === "string"
? [rawUrls]
: Array.isArray(rawUrls) && rawUrls.every((url): url is string => typeof url === "string")
? [...rawUrls]
: undefined;
if (!urls || urls.length === 0) throw sourceFailure("invalid-response", false);
urlCount += urls.length;
if (urlCount > CLOUDFLARE_TURN_MAX_ICE_SERVER_URLS || urls.some((url) => !isSupportedIceServerUrl(url))) {
throw sourceFailure("invalid-response", false);
}
const turnEntry = urls.some(isTurnUrl);
hasTurnServer ||= turnEntry;
const normalised: RTCIceServer = { urls };
if (turnEntry) {
if (!isCredential(candidate.username) || !isCredential(candidate.credential)) {
throw sourceFailure("invalid-response", false);
}
normalised.username = candidate.username;
normalised.credential = candidate.credential;
}
servers.push(normalised);
}
if (!hasTurnServer) throw sourceFailure("invalid-response", false);
return Object.freeze(servers);
}
class BoundedResponseError extends Error {
constructor(readonly kind: "too-large" | "invalid-length" | "read-failed") {
super(kind);
}
}
async function readResponseBody(response: Response): Promise<string> {
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
const declaredLength = Number(contentLength);
if (!Number.isFinite(declaredLength) || declaredLength < 0) {
throw new BoundedResponseError("invalid-length");
}
if (declaredLength > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
throw new BoundedResponseError("too-large");
}
}
if (!response.body) {
try {
const text = await response.text();
if (new TextEncoder().encode(text).byteLength > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
throw new BoundedResponseError("too-large");
}
return text;
} catch (error) {
if (error instanceof BoundedResponseError) throw error;
throw new BoundedResponseError("read-failed");
}
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const result = await reader.read();
if (result.done) break;
totalBytes += result.value.byteLength;
if (totalBytes > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
try {
await reader.cancel();
} catch {
// The response is already invalid because it exceeded the
// bound; cancellation failure must not change the safe
// classification or expose a host-specific error.
}
throw new BoundedResponseError("too-large");
}
chunks.push(result.value);
}
} catch (error) {
if (error instanceof BoundedResponseError) throw error;
throw new BoundedResponseError("read-failed");
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}
function classifyHttpFailure(status: number): IceServerSourceError {
if (status === 401 || status === 403) {
return sourceFailure("authentication", false);
}
if (status === 408 || status === 429 || status >= 500) {
return sourceFailure("unavailable", true);
}
return sourceFailure("unavailable", false);
}
function parseResponseBody(body: string): readonly RTCIceServer[] {
let value: unknown;
try {
value = JSON.parse(body) as unknown;
} catch {
throw sourceFailure("invalid-response", false);
}
return normaliseIceServers(value);
}
function createSource(
configuration: CloudflareIceServerSourceConfiguration,
dependencies: CloudflareIceServerSourceDependencies
): IceServerSource {
const now = dependencies.now ?? Date.now;
const requestDeadlineMs = dependencies.requestDeadlineMs ?? CLOUDFLARE_TURN_REQUEST_DEADLINE_MS;
return {
async acquire(signal: AbortSignal): Promise<IceServerConfiguration> {
throwIfAborted(signal);
const requestStartedAt = now();
if (!Number.isFinite(requestStartedAt)) {
throw sourceFailure("unavailable", true);
}
const requestController = new AbortController();
let cancelledByCaller = false;
let rejectCaller: ((reason?: unknown) => void) | undefined;
const callerAbort = new Promise<never>((_resolve, reject) => {
rejectCaller = reject;
});
let timedOut = false;
const onAbort = () => {
cancelledByCaller = true;
requestController.abort();
rejectCaller?.(abortError());
};
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) {
signal.removeEventListener("abort", onAbort);
requestController.abort();
throw abortError();
}
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_resolve, reject) => {
timeoutId = globalThis.setTimeout(() => {
timedOut = true;
requestController.abort();
reject(sourceFailure("unavailable", true));
}, requestDeadlineMs);
});
const cleanup = () => {
if (timeoutId !== undefined) globalThis.clearTimeout(timeoutId);
signal.removeEventListener("abort", onAbort);
};
const endpoint = `${CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT}/${configuration.turnKeyId}/credentials/generate-ice-servers`;
let response: Response;
try {
response = await Promise.race([
dependencies.fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${configuration.apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ttl: CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS }),
signal: requestController.signal,
redirect: "error",
credentials: "omit",
cache: "no-store",
}),
callerAbort,
deadline,
]);
} catch {
cleanup();
if (cancelledByCaller || signal.aborted) throw abortError();
if (timedOut) throw sourceFailure("unavailable", true);
throw sourceFailure("unavailable", true);
}
if (cancelledByCaller || signal.aborted) {
cleanup();
throw abortError();
}
if (timedOut || requestController.signal.aborted) {
cleanup();
throw sourceFailure("unavailable", true);
}
if (response.status !== 201) {
cleanup();
throw classifyHttpFailure(response.status);
}
let body: string;
try {
body = await Promise.race([readResponseBody(response), callerAbort, deadline]);
} catch (error) {
cleanup();
if (cancelledByCaller || signal.aborted) throw abortError();
if (timedOut) throw sourceFailure("unavailable", true);
if (error instanceof BoundedResponseError && error.kind === "read-failed") {
throw sourceFailure("unavailable", true);
}
throw sourceFailure("invalid-response", false);
}
try {
throwIfAborted(signal);
const iceServers = parseResponseBody(body);
const expiresAt = requestStartedAt + CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000;
if (!Number.isFinite(expiresAt) || expiresAt <= now() + CLOUDFLARE_TURN_MIN_REMAINING_LIFETIME_MS) {
throw sourceFailure("invalid-response", false);
}
return { iceServers, expiresAt };
} finally {
cleanup();
}
},
};
}
/**
* Creates a Cloudflare source after validating its persisted configuration.
* Validation is synchronous and performs no network request.
*/
export function createCloudflareIceServerSource(
configuration: Readonly<Record<string, unknown>>,
dependencies: CloudflareIceServerSourceDependencies
): IceServerSource {
const parsed = parseCloudflareIceServerSourceConfiguration(configuration);
if (!parsed) throw sourceFailure("configuration", false);
return createSource(parsed, dependencies);
}
/** Exposes the provider validation for the integration catalogue and UI. */
export { validateCloudflareIceServerSourceConfiguration };
@@ -0,0 +1,164 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CLOUDFLARE_TURN_MAX_RESPONSE_BYTES,
CLOUDFLARE_TURN_REQUEST_DEADLINE_MS,
createCloudflareIceServerSource,
} from "./iceServerSource";
import {
CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT,
CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS,
validateCloudflareIceServerSourceConfiguration,
} from "./settings";
const configuration = {
turnKeyId: "key-123",
apiToken: "token_abc-123",
} as const;
function response(body: unknown, status = 201): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function validBody() {
return {
iceServers: [
{
urls: ["turn:relay.example.test:3478?transport=udp", "turns:relay.example.test:5349"],
username: "turn-user",
credential: "turn-password",
},
{ urls: "stun:stun.example.test:3478" },
],
};
}
afterEach(() => {
vi.useRealTimers();
});
describe("Cloudflare ICE server source", () => {
it("requests the fixed endpoint with the bearer token and TTL", async () => {
const now = 1_000_000;
let requestUrl: string | Request | undefined;
let requestInit: RequestInit | undefined;
const fetch = vi.fn(async (input: string | Request, init?: RequestInit) => {
requestUrl = input;
requestInit = init;
return response(validBody());
});
const source = createCloudflareIceServerSource(configuration, { fetch, now: () => now });
const result = await source.acquire(new AbortController().signal);
expect(requestUrl).toBe(`${CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT}/key-123/credentials/generate-ice-servers`);
expect(requestInit).toMatchObject({
method: "POST",
redirect: "error",
credentials: "omit",
cache: "no-store",
body: JSON.stringify({ ttl: CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS }),
});
expect(new Headers(requestInit?.headers).get("authorization")).toBe("Bearer token_abc-123");
expect(new Headers(requestInit?.headers).get("content-type")).toBe("application/json");
expect(requestInit?.signal).toBeInstanceOf(AbortSignal);
expect(result.iceServers).toHaveLength(2);
expect(result.expiresAt).toBe(now + CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000);
});
it("rejects malformed, oversized, and STUN-only responses without exposing secrets", async () => {
const cases: Array<{ body: unknown; expectedCode: string }> = [
{ body: { iceServers: [] }, expectedCode: "invalid-response" },
{ body: { iceServers: [{ urls: "turn:relay.example.test:3478" }] }, expectedCode: "invalid-response" },
{ body: { iceServers: [{ urls: "stun:stun.example.test:3478" }] }, expectedCode: "invalid-response" },
];
for (const testCase of cases) {
const source = createCloudflareIceServerSource(configuration, {
fetch: vi.fn(async () => response(testCase.body)),
now: () => 1_000_000,
});
const error = await source.acquire(new AbortController().signal).catch((reason: unknown) => reason);
expect(error).toMatchObject({ code: testCase.expectedCode });
expect(String(error)).not.toContain(configuration.apiToken);
expect(String(error)).not.toContain(configuration.turnKeyId);
}
const oversized = "x".repeat(CLOUDFLARE_TURN_MAX_RESPONSE_BYTES + 1);
const source = createCloudflareIceServerSource(configuration, {
fetch: vi.fn(async () => new Response(oversized, { status: 201 })),
now: () => 1_000_000,
});
const error = await source.acquire(new AbortController().signal).catch((reason: unknown) => reason);
expect(error).toMatchObject({ code: "invalid-response" });
});
it("classifies authentication and transient provider failures", async () => {
const authSource = createCloudflareIceServerSource(configuration, {
fetch: vi.fn(async () => response({}, 401)),
});
await expect(authSource.acquire(new AbortController().signal)).rejects.toMatchObject({
code: "authentication",
retryable: false,
});
const transientSource = createCloudflareIceServerSource(configuration, {
fetch: vi.fn(async () => response({}, 503)),
});
await expect(transientSource.acquire(new AbortController().signal)).rejects.toMatchObject({
code: "unavailable",
retryable: true,
});
});
it("propagates caller cancellation and turns a deadline into an unavailable failure", async () => {
const controller = new AbortController();
const fetch = vi.fn((_input: string | Request, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), {
once: true,
});
});
});
const source = createCloudflareIceServerSource(configuration, { fetch });
const cancelled = source.acquire(controller.signal);
controller.abort();
await expect(cancelled).rejects.toMatchObject({ name: "AbortError" });
vi.useFakeTimers();
const timedSource = createCloudflareIceServerSource(configuration, { fetch });
const timed = timedSource.acquire(new AbortController().signal);
const assertion = expect(timed).rejects.toMatchObject({ code: "unavailable", retryable: true });
await vi.advanceTimersByTimeAsync(CLOUDFLARE_TURN_REQUEST_DEADLINE_MS);
await assertion;
});
it("rejects an issuance which has no usable remaining lifetime", async () => {
let now = 1_000_000;
const source = createCloudflareIceServerSource(configuration, {
fetch: vi.fn(async () => {
now += CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000;
return response(validBody());
}),
now: () => now,
});
await expect(source.acquire(new AbortController().signal)).rejects.toMatchObject({
code: "invalid-response",
});
});
});
describe("Cloudflare ICE source validation", () => {
it("rejects unknown fields and malformed bearer credentials", () => {
expect(validateCloudflareIceServerSourceConfiguration({ ...configuration, unexpected: "value" })).toContain(
"unsupported field"
);
expect(
validateCloudflareIceServerSourceConfiguration({ turnKeyId: "key/id", apiToken: configuration.apiToken })
).toContain("unsupported characters");
expect(
validateCloudflareIceServerSourceConfiguration({ ...configuration, apiToken: "token with spaces" })
).toContain("Bearer token syntax");
});
});
+87
View File
@@ -0,0 +1,87 @@
/** The source identifier persisted in a P2P profile for Cloudflare TURN. */
export const CLOUDFLARE_ICE_SERVER_SOURCE_ID = "cloudflare" as const;
/** The lifetime requested from Cloudflare for each issued credential set. */
export const CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS = 86_400 as const;
/** The Cloudflare TURN credential-generation endpoint. */
export const CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT = "https://rtc.live.cloudflare.com/v1/turn/keys" as const;
/** A validated Cloudflare TURN source configuration. */
export interface CloudflareIceServerSourceConfiguration {
readonly turnKeyId: string;
readonly apiToken: string;
}
const CLOUDFLARE_CONFIGURATION_KEYS = ["turnKeyId", "apiToken"] as const;
// TURN Key IDs are inserted into one fixed URL path. Keep the accepted set
// deliberately narrower than URI escaping so a configuration cannot alter
// the request path or add a query string.
const TURN_KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/;
// RFC 6750's b64token grammar, including optional trailing padding. This
// also excludes whitespace and control characters from the Authorization
// header without exposing the token in a validation message.
const BEARER_TOKEN_PATTERN = /^[A-Za-z0-9._~+/-]+={0,2}$/;
const MAX_BEARER_TOKEN_LENGTH = 4_096;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasOnlyCloudflareConfigurationKeys(value: Record<string, unknown>): boolean {
const keys = Object.keys(value);
return (
keys.length === CLOUDFLARE_CONFIGURATION_KEYS.length &&
CLOUDFLARE_CONFIGURATION_KEYS.every((key) => Object.prototype.hasOwnProperty.call(value, key))
);
}
/**
* Returns a safe validation message for a Cloudflare source configuration.
* The result never includes the supplied Key ID or API token.
*/
export function validateCloudflareIceServerSourceConfiguration(value: unknown): string | undefined {
if (!isRecord(value)) {
return "Cloudflare TURN configuration is invalid.";
}
if (!hasOnlyCloudflareConfigurationKeys(value)) {
return "Cloudflare TURN configuration contains an unsupported field.";
}
const turnKeyId = value.turnKeyId;
if (typeof turnKeyId !== "string" || turnKeyId.length === 0) {
return "Enter a TURN Key ID.";
}
if (!TURN_KEY_ID_PATTERN.test(turnKeyId)) {
return "TURN Key ID contains unsupported characters.";
}
const apiToken = value.apiToken;
if (typeof apiToken !== "string" || apiToken.length === 0) {
return "Enter a TURN Key API Token.";
}
if (apiToken.length > MAX_BEARER_TOKEN_LENGTH || !BEARER_TOKEN_PATTERN.test(apiToken)) {
return "TURN Key API Token must use Bearer token syntax.";
}
return undefined;
}
/**
* Converts an untrusted profile value into a validated source configuration.
* The returned object is a fresh copy so later settings mutations cannot
* change a source which is already being used by the P2P owner.
*/
export function parseCloudflareIceServerSourceConfiguration(
value: unknown
): CloudflareIceServerSourceConfiguration | undefined {
if (validateCloudflareIceServerSourceConfiguration(value) !== undefined || !isRecord(value)) {
return undefined;
}
return {
turnKeyId: value.turnKeyId as string,
apiToken: value.apiToken as string,
};
}
+85
View File
@@ -0,0 +1,85 @@
import { CLOUDFLARE_ICE_SERVER_SOURCE_ID, validateCloudflareIceServerSourceConfiguration } from "./cloudflare/settings";
export const MANUAL_ICE_SERVER_SOURCE_ID = "manual" as const;
export type IceServerSourceSelectionId = typeof MANUAL_ICE_SERVER_SOURCE_ID | typeof CLOUDFLARE_ICE_SERVER_SOURCE_ID;
export interface IceServerSourceFieldDefinition {
readonly key: string;
readonly label: string;
readonly secret: boolean;
}
export interface IceServerSourceDefinition {
readonly id: string;
readonly label: string;
readonly fields: readonly IceServerSourceFieldDefinition[];
}
export interface IceServerSourceDescriptorLike {
readonly version?: unknown;
readonly id?: unknown;
readonly configuration?: unknown;
}
/**
* The service-owned field metadata used by the P2P settings dialogue. Manual
* TURN values remain the existing settings fields and therefore do not occur
* in this provider catalogue.
*/
export const iceServerSourceDefinitions = [
{
id: CLOUDFLARE_ICE_SERVER_SOURCE_ID,
label: "Cloudflare",
fields: [
{ key: "turnKeyId", label: "TURN Key ID", secret: false },
{ key: "apiToken", label: "TURN Key API Token", secret: true },
],
},
] as const satisfies readonly IceServerSourceDefinition[];
/** The user-facing source choice, including the existing manual mode. */
export const turnConfigurationChoices = [
{ id: MANUAL_ICE_SERVER_SOURCE_ID, label: "Manual" },
{ id: CLOUDFLARE_ICE_SERVER_SOURCE_ID, label: "Cloudflare" },
] as const;
export const iceServerSourceChoices = turnConfigurationChoices;
function isRecord(value: unknown): value is IceServerSourceDescriptorLike {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Validates a selected source descriptor without performing network access.
* An absent descriptor represents the existing manual TURN configuration.
*/
export function validateIceServerSourceConfiguration(
descriptor: IceServerSourceDescriptorLike | null | undefined
): string | undefined {
if (descriptor === undefined || descriptor === null) return undefined;
if (!isRecord(descriptor)) return "TURN configuration source is invalid.";
if (descriptor.version !== 1) return "TURN configuration source version is not supported.";
if (descriptor.id === MANUAL_ICE_SERVER_SOURCE_ID) {
return undefined;
}
if (descriptor.id !== CLOUDFLARE_ICE_SERVER_SOURCE_ID) {
return "The selected TURN configuration source is not supported.";
}
return validateCloudflareIceServerSourceConfiguration(descriptor.configuration);
}
export function getIceServerSourceDefinition(id: string): IceServerSourceDefinition | undefined {
return iceServerSourceDefinitions.find((definition) => definition.id === id);
}
/** Validate the selected settings projection, including an unavailable encrypted source. */
export function validateTurnSettings(settings: {
readonly P2P_iceServerSource?: IceServerSourceDescriptorLike | null;
readonly encryptedP2PIceServerSource?: string;
}): string | undefined {
if (!settings.P2P_iceServerSource && settings.encryptedP2PIceServerSource) {
return "TURN configuration could not be decrypted.";
}
return validateIceServerSourceConfiguration(settings.P2P_iceServerSource);
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
iceServerSourceDefinitions,
validateIceServerSourceConfiguration,
validateTurnSettings,
} from "./iceServerSources";
describe("ICE server source catalogue", () => {
it("blocks an unavailable encrypted source instead of presenting manual settings as valid", () => {
expect(validateTurnSettings({ encryptedP2PIceServerSource: "private-ciphertext" })).toBe(
"TURN configuration could not be decrypted."
);
expect(validateTurnSettings({})).toBeUndefined();
});
it("describes the Cloudflare fields without owning manual TURN fields", () => {
expect(iceServerSourceDefinitions).toEqual([
{
id: "cloudflare",
label: "Cloudflare",
fields: [
{ key: "turnKeyId", label: "TURN Key ID", secret: false },
{ key: "apiToken", label: "TURN Key API Token", secret: true },
],
},
]);
});
it("accepts absent or explicit manual selection and rejects unsupported versions", () => {
expect(validateIceServerSourceConfiguration(undefined)).toBeUndefined();
expect(validateIceServerSourceConfiguration({ version: 1, id: "manual" })).toBeUndefined();
expect(validateIceServerSourceConfiguration({ version: 2, id: "cloudflare", configuration: {} })).toContain(
"version"
);
expect(validateIceServerSourceConfiguration({ version: 1, id: "unknown", configuration: {} })).toContain(
"not supported"
);
});
});