Use host preparation for TURN connection settings

This commit is contained in:
vorotamoroz
2026-09-16 03:43:09 +00:00
parent a565070809
commit 11a07b26af
33 changed files with 654 additions and 961 deletions
+6 -44
View File
@@ -1,5 +1,5 @@
/** The source identifier persisted in a P2P profile for Cloudflare TURN. */
export const CLOUDFLARE_ICE_SERVER_SOURCE_ID = "cloudflare" as const;
/** The provider identifier persisted in a P2P profile for Cloudflare TURN. */
export const CLOUDFLARE_TURN_TYPE = "CF" as const;
/** The lifetime requested from Cloudflare for each issued credential set. */
export const CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS = 86_400 as const;
@@ -7,14 +7,12 @@ 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 {
/** A Cloudflare TURN configuration. */
export interface CloudflareTurnConfiguration {
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.
@@ -26,30 +24,11 @@ const TURN_KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/;
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.
* Returns a safe validation message for a Cloudflare TURN 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.";
}
export function validateCloudflareTurnConfiguration(value: CloudflareTurnConfiguration): string | undefined {
const turnKeyId = value.turnKeyId;
if (typeof turnKeyId !== "string" || turnKeyId.length === 0) {
return "Enter a TURN Key ID.";
@@ -68,20 +47,3 @@ export function validateCloudflareIceServerSourceConfiguration(value: unknown):
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,
};
}
@@ -1,18 +1,15 @@
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,
type CloudflareTurnConfiguration,
validateCloudflareTurnConfiguration,
} from "./settings";
/** Fetch-compatible function supplied by the host composition. */
export type CloudflareIceServerSourceFetch = (input: string | Request, init?: RequestInit) => Promise<Response>;
export type CloudflareTurnFetch = (input: string | Request, init?: RequestInit) => Promise<Response>;
export interface CloudflareIceServerSourceDependencies {
readonly fetch: CloudflareIceServerSourceFetch;
export interface CloudflareTurnDependencies {
readonly fetch: CloudflareTurnFetch;
readonly now?: () => number;
readonly requestDeadlineMs?: number;
}
@@ -21,19 +18,19 @@ 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;
export const CLOUDFLARE_TURN_MIN_REMAINING_LIFETIME_MS = 30_000 as const;
type IceServerSourceFailureCode = "configuration" | "authentication" | "unavailable" | "invalid-response";
type TurnFailureCode = "configuration" | "authentication" | "unavailable" | "invalid-response";
const SOURCE_FAILURE_MESSAGES: Record<IceServerSourceFailureCode, string> = {
configuration: "The Cloudflare TURN source configuration is invalid.",
const FAILURE_MESSAGES: Record<TurnFailureCode, string> = {
configuration: "The Cloudflare TURN 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 credentialFailure(code: TurnFailureCode, retryable: boolean): Error {
return Object.assign(new Error(FAILURE_MESSAGES[code]), { code, retryable });
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -123,10 +120,10 @@ function isCredential(value: unknown): value is string {
function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
if (!isRecord(value) || !Array.isArray(value.iceServers)) {
throw sourceFailure("invalid-response", false);
throw credentialFailure("invalid-response", false);
}
if (value.iceServers.length === 0 || value.iceServers.length > CLOUDFLARE_TURN_MAX_ICE_SERVER_ENTRIES) {
throw sourceFailure("invalid-response", false);
throw credentialFailure("invalid-response", false);
}
const servers: RTCIceServer[] = [];
@@ -134,7 +131,7 @@ function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
let hasTurnServer = false;
for (const candidate of value.iceServers) {
if (!isRecord(candidate)) throw sourceFailure("invalid-response", false);
if (!isRecord(candidate)) throw credentialFailure("invalid-response", false);
const rawUrls = candidate.urls;
const urls =
typeof rawUrls === "string"
@@ -142,11 +139,11 @@ function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
: Array.isArray(rawUrls) && rawUrls.every((url): url is string => typeof url === "string")
? [...rawUrls]
: undefined;
if (!urls || urls.length === 0) throw sourceFailure("invalid-response", false);
if (!urls || urls.length === 0) throw credentialFailure("invalid-response", false);
urlCount += urls.length;
if (urlCount > CLOUDFLARE_TURN_MAX_ICE_SERVER_URLS || urls.some((url) => !isSupportedIceServerUrl(url))) {
throw sourceFailure("invalid-response", false);
throw credentialFailure("invalid-response", false);
}
const turnEntry = urls.some(isTurnUrl);
@@ -154,7 +151,7 @@ function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
const normalised: RTCIceServer = { urls };
if (turnEntry) {
if (!isCredential(candidate.username) || !isCredential(candidate.credential)) {
throw sourceFailure("invalid-response", false);
throw credentialFailure("invalid-response", false);
}
normalised.username = candidate.username;
normalised.credential = candidate.credential;
@@ -162,7 +159,7 @@ function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
servers.push(normalised);
}
if (!hasTurnServer) throw sourceFailure("invalid-response", false);
if (!hasTurnServer) throw credentialFailure("invalid-response", false);
return Object.freeze(servers);
}
@@ -233,14 +230,14 @@ async function readResponseBody(response: Response): Promise<string> {
return new TextDecoder().decode(bytes);
}
function classifyHttpFailure(status: number): IceServerSourceError {
function classifyHttpFailure(status: number): Error {
if (status === 401 || status === 403) {
return sourceFailure("authentication", false);
return credentialFailure("authentication", false);
}
if (status === 408 || status === 429 || status >= 500) {
return sourceFailure("unavailable", true);
return credentialFailure("unavailable", true);
}
return sourceFailure("unavailable", false);
return credentialFailure("unavailable", false);
}
function parseResponseBody(body: string): readonly RTCIceServer[] {
@@ -248,137 +245,119 @@ function parseResponseBody(body: string): readonly RTCIceServer[] {
try {
value = JSON.parse(body) as unknown;
} catch {
throw sourceFailure("invalid-response", false);
throw credentialFailure("invalid-response", false);
}
return normaliseIceServers(value);
}
function createSource(
configuration: CloudflareIceServerSourceConfiguration,
dependencies: CloudflareIceServerSourceDependencies
): IceServerSource {
/** Acquire one temporary ICE configuration for a new room connection. */
export async function acquireCloudflareTurnCredentials(
configuration: CloudflareTurnConfiguration,
dependencies: CloudflareTurnDependencies,
signal: AbortSignal
): Promise<{ iceServers: readonly RTCIceServer[]; expiresAt: number }> {
if (validateCloudflareTurnConfiguration(configuration)) throw credentialFailure("configuration", false);
const now = dependencies.now ?? Date.now;
const requestDeadlineMs = dependencies.requestDeadlineMs ?? CLOUDFLARE_TURN_REQUEST_DEADLINE_MS;
throwIfAborted(signal);
const requestStartedAt = now();
if (!Number.isFinite(requestStartedAt)) {
throw credentialFailure("unavailable", true);
}
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();
}
},
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(credentialFailure("unavailable", true));
}, requestDeadlineMs);
});
/**
* 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);
}
const cleanup = () => {
if (timeoutId !== undefined) globalThis.clearTimeout(timeoutId);
signal.removeEventListener("abort", onAbort);
};
/** Exposes the provider validation for the integration catalogue and UI. */
export { validateCloudflareIceServerSourceConfiguration };
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 credentialFailure("unavailable", true);
throw credentialFailure("unavailable", true);
}
if (cancelledByCaller || signal.aborted) {
cleanup();
throw abortError();
}
if (timedOut || requestController.signal.aborted) {
cleanup();
throw credentialFailure("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 credentialFailure("unavailable", true);
if (error instanceof BoundedResponseError && error.kind === "read-failed") {
throw credentialFailure("unavailable", true);
}
throw credentialFailure("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 credentialFailure("invalid-response", false);
}
return { iceServers, expiresAt };
} finally {
cleanup();
}
}
@@ -2,12 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
CLOUDFLARE_TURN_MAX_RESPONSE_BYTES,
CLOUDFLARE_TURN_REQUEST_DEADLINE_MS,
createCloudflareIceServerSource,
} from "./iceServerSource";
acquireCloudflareTurnCredentials,
} from "./turnCredentials";
import {
CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT,
CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS,
validateCloudflareIceServerSourceConfiguration,
validateCloudflareTurnConfiguration,
} from "./settings";
const configuration = {
@@ -39,7 +39,7 @@ afterEach(() => {
vi.useRealTimers();
});
describe("Cloudflare ICE server source", () => {
describe("Cloudflare TURN credentials", () => {
it("requests the fixed endpoint with the bearer token and TTL", async () => {
const now = 1_000_000;
let requestUrl: string | Request | undefined;
@@ -49,9 +49,13 @@ describe("Cloudflare ICE server source", () => {
requestInit = init;
return response(validBody());
});
const source = createCloudflareIceServerSource(configuration, { fetch, now: () => now });
const dependencies = { fetch, now: () => now };
const result = await source.acquire(new AbortController().signal);
const result = await acquireCloudflareTurnCredentials(
configuration,
dependencies,
new AbortController().signal
);
expect(requestUrl).toBe(`${CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT}/key-123/credentials/generate-ice-servers`);
expect(requestInit).toMatchObject({
@@ -75,38 +79,50 @@ describe("Cloudflare ICE server source", () => {
{ body: { iceServers: [{ urls: "stun:stun.example.test:3478" }] }, expectedCode: "invalid-response" },
];
for (const testCase of cases) {
const source = createCloudflareIceServerSource(configuration, {
const dependencies = {
fetch: vi.fn(async () => response(testCase.body)),
now: () => 1_000_000,
});
const error = await source.acquire(new AbortController().signal).catch((reason: unknown) => reason);
};
const error = await acquireCloudflareTurnCredentials(
configuration,
dependencies,
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, {
const dependencies = {
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);
};
const error = await acquireCloudflareTurnCredentials(
configuration,
dependencies,
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, {
const authDependencies = {
fetch: vi.fn(async () => response({}, 401)),
});
await expect(authSource.acquire(new AbortController().signal)).rejects.toMatchObject({
};
await expect(
acquireCloudflareTurnCredentials(configuration, authDependencies, new AbortController().signal)
).rejects.toMatchObject({
code: "authentication",
retryable: false,
});
const transientSource = createCloudflareIceServerSource(configuration, {
const transientDependencies = {
fetch: vi.fn(async () => response({}, 503)),
});
await expect(transientSource.acquire(new AbortController().signal)).rejects.toMatchObject({
};
await expect(
acquireCloudflareTurnCredentials(configuration, transientDependencies, new AbortController().signal)
).rejects.toMatchObject({
code: "unavailable",
retryable: true,
});
@@ -121,14 +137,14 @@ describe("Cloudflare ICE server source", () => {
});
});
});
const source = createCloudflareIceServerSource(configuration, { fetch });
const cancelled = source.acquire(controller.signal);
const dependencies = { fetch };
const cancelled = acquireCloudflareTurnCredentials(configuration, dependencies, 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 timedDependencies = { fetch };
const timed = acquireCloudflareTurnCredentials(configuration, timedDependencies, new AbortController().signal);
const assertion = expect(timed).rejects.toMatchObject({ code: "unavailable", retryable: true });
await vi.advanceTimersByTimeAsync(CLOUDFLARE_TURN_REQUEST_DEADLINE_MS);
await assertion;
@@ -136,29 +152,28 @@ describe("Cloudflare ICE server source", () => {
it("rejects an issuance which has no usable remaining lifetime", async () => {
let now = 1_000_000;
const source = createCloudflareIceServerSource(configuration, {
const dependencies = {
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({
};
await expect(
acquireCloudflareTurnCredentials(configuration, dependencies, 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"
);
describe("Cloudflare TURN input validation", () => {
it("rejects unsafe key IDs and malformed bearer credentials", () => {
expect(
validateCloudflareIceServerSourceConfiguration({ turnKeyId: "key/id", apiToken: configuration.apiToken })
validateCloudflareTurnConfiguration({ turnKeyId: "key/id", apiToken: configuration.apiToken })
).toContain("unsupported characters");
expect(
validateCloudflareIceServerSourceConfiguration({ ...configuration, apiToken: "token with spaces" })
).toContain("Bearer token syntax");
expect(validateCloudflareTurnConfiguration({ ...configuration, apiToken: "token with spaces" })).toContain(
"Bearer token syntax"
);
});
});
-74
View File
@@ -1,74 +0,0 @@
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);
}
@@ -1,31 +0,0 @@
import { describe, expect, it } from "vitest";
import {
iceServerSourceDefinitions,
validateIceServerSourceConfiguration,
} from "./iceServerSources";
describe("ICE server source catalogue", () => {
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"
);
});
});
+14
View File
@@ -0,0 +1,14 @@
import type { P2PConnectionInfo } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { CLOUDFLARE_TURN_TYPE, validateCloudflareTurnConfiguration } from "./cloudflare/settings";
/** Validate provider inputs without requesting credentials. */
export function validateManagedTurnSettings(settings: Partial<P2PConnectionInfo>): string | undefined {
if (settings.P2P_managedType === undefined || settings.P2P_managedType === "") return undefined;
if (settings.P2P_managedType !== CLOUDFLARE_TURN_TYPE) {
return "The selected TURN configuration is not supported.";
}
return validateCloudflareTurnConfiguration({
turnKeyId: settings.P2P_managedId ?? "",
apiToken: settings.P2P_managedToken ?? "",
});
}