Editor/Core/CloudflareApi.cs
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
namespace TeamCreate;
/// <summary>One call to Cloudflare. The transport adds nothing: the caller builds every header, including the credential.</summary>
public sealed class CloudflareRequest
{
public string Method { get; init; }
public string Url { get; init; }
public Dictionary<string, string> Headers { get; init; } = new Dictionary<string, string>();
public HttpContent Content { get; init; }
}
/// <summary>What came back. <see cref="Status"/> is null when nothing answered (offline, refused by the engine, timed out), and <see cref="Failure"/> says why.</summary>
public sealed record CloudflareResponse(int? Status, string Body, string Failure = null);
/// <summary>Sends a request to Cloudflare. The editor uses the engine's HTTP client; the tests use a plain one against a local stand-in.</summary>
public interface ICloudflareTransport
{
Task<CloudflareResponse> SendAsync(CloudflareRequest request, CancellationToken cancellationToken);
}
/// <summary>
/// Everything the editor needs to know about Cloudflare's Workers API in order to deploy the relay itself, so a person with no command-line tools can set the
/// relay up from the dock. The request shapes are the ones Wrangler (Cloudflare's own tool) sends for the same Worker, and Tools/RelayWorker/test checks them
/// against what Wrangler actually puts on the wire.
/// </summary>
public static class CloudflareApi
{
public const string Base = "https://api.cloudflare.com/client/v4";
public const string ScriptName = "collab-relay";
/// <summary>The module's part name in the upload. It must equal <c>main_module</c> in the metadata.</summary>
public const string MainModule = "index.js";
public const string DurableObjectClass = "Room";
public const string BindingName = "ROOMS";
public const string MigrationTag = "v1";
public const string CompatibilityDate = "2026-09-01";
/// <summary>Selects the current shape of the per-script workers.dev switch, as Wrangler does.</summary>
/// <summary>Wrangler's upload query: a short response, and bindings the upload does not name are not carried over from the previous version.</summary>
public const string UploadQuery = "?excludeScript=true&bindings_inherit=strict";
public const string ScriptApiDateHeader = "Cloudflare-Workers-Script-Api-Date";
public const string ScriptApiDate = "2025-08-01";
private const int MaxTokenChars = 200;
private const int MinTokenChars = 30;
private static readonly string[] KnownTokenPrefixes = { "cfut_", "cfat_", "cfk_" };
/// <summary>
/// The page where a person creates the token, with the two permissions the deploy needs already chosen: editing Workers scripts and reading the account.
/// Nothing else is requested, so the token cannot touch DNS, billing or anything outside Workers.
/// </summary>
public static string TokenPageUrl()
{
string permissions = "[{\"key\":\"workers_scripts\",\"type\":\"edit\"},{\"key\":\"account_settings\",\"type\":\"read\"}]";
return "https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=" + Uri.EscapeDataString(permissions) + "&accountId=*&zoneId=all&name=" + Uri.EscapeDataString("s&box Editor Collaboration relay");
}
/// <summary>Where a person deletes the token again once the relay is deployed.</summary>
public const string TokenListUrl = "https://dash.cloudflare.com/profile/api-tokens";
public static string RelayUrlFor(string workersDevHost)
{
return "wss://" + workersDevHost + "/relay";
}
/// <summary>The address a relay answers health checks on, derived from the relay's own address. Null when the address is not a relay address.</summary>
public static string HealthUrlFor(string relayUrl)
{
if (!Uri.TryCreate(relayUrl?.Trim(), UriKind.Absolute, out Uri uri) || (uri.Scheme != "wss" && uri.Scheme != "ws"))
{
return null;
}
return (uri.Scheme == "wss" ? "https://" : "http://") + uri.Authority + "/health";
}
/// <summary>
/// Decides whether the clipboard holds a Cloudflare token before anything is sent anywhere. The text goes to Cloudflare as a credential, so this refuses
/// what is certainly something else, above all an invite code (which carries the session's encryption secret) or a relay access key.
/// </summary>
public static bool TryAcceptToken(string clipboard, out string token, out string problem)
{
token = null;
problem = null;
if (string.IsNullOrWhiteSpace(clipboard))
{
problem = "The clipboard is empty. Copy the token from the Cloudflare page first.";
return false;
}
string text = clipboard.Trim();
foreach (char c in text)
{
if (char.IsWhiteSpace(c) || char.IsControl(c))
{
problem = "The clipboard holds more than a single token. Copy only the token from the Cloudflare page. Nothing was sent.";
return false;
}
}
if (text.Length < MinTokenChars || text.Length > MaxTokenChars)
{
problem = "The clipboard does not hold a Cloudflare token (wrong length). Copy the token from the Cloudflare page. Nothing was sent.";
return false;
}
foreach (char c in text)
{
bool allowed = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-';
if (!allowed)
{
problem = "The clipboard does not hold a Cloudflare token (it contains other characters, such as a web address). Copy the token itself. Nothing was sent.";
return false;
}
}
if (InviteCode.TryDecode(text, out _, out _))
{
problem = "The clipboard holds an invite code, not a Cloudflare token. Nothing was sent.";
return false;
}
bool knownPrefix = false;
foreach (string prefix in KnownTokenPrefixes)
{
knownPrefix |= text.StartsWith(prefix, StringComparison.Ordinal);
}
if (!knownPrefix && !HasBothLetterCases(text))
{
// Real tokens are random mixed-case text. All one case is an invite code, a hexadecimal key or a hash: something that must never be sent to Cloudflare.
problem = "The clipboard does not look like a Cloudflare token. Copy the token from the Cloudflare page. Nothing was sent.";
return false;
}
token = text;
return true;
}
private static bool HasBothLetterCases(string text)
{
bool lower = false;
bool upper = false;
foreach (char c in text)
{
lower |= c >= 'a' && c <= 'z';
upper |= c >= 'A' && c <= 'Z';
}
return lower && upper;
}
/// <summary>The upload's metadata part. Migrations are sent only while the Worker has not yet had them applied, exactly as Wrangler decides it.</summary>
public static string UploadMetadataJson(bool sendMigrations, bool keepSecrets)
{
var metadata = new JsonObject
{
["main_module"] = MainModule,
["bindings"] = new JsonArray
{
new JsonObject { ["type"] = "durable_object_namespace", ["name"] = BindingName, ["class_name"] = DurableObjectClass }
},
["compatibility_date"] = CompatibilityDate
};
if (sendMigrations)
{
// SQLite-backed Durable Objects are the only kind the free plan allows.
metadata["migrations"] = new JsonObject
{
["new_tag"] = MigrationTag,
["steps"] = new JsonArray { new JsonObject { ["new_sqlite_classes"] = new JsonArray { DurableObjectClass } } }
};
}
if (keepSecrets)
{
// A relay access key the owner set as a secret survives a redeploy instead of being wiped.
metadata["keep_bindings"] = new JsonArray { "secret_text", "secret_key" };
}
return metadata.ToJsonString();
}
/// <summary>The multipart body of a script upload. The boundary is sent unquoted, which every server accepts and some do not accept quoted.</summary>
public static MultipartFormDataContent UploadContent(string workerSource, bool sendMigrations, bool keepSecrets)
{
string boundary = "collab-" + Guid.NewGuid().ToString("N");
var content = new MultipartFormDataContent(boundary);
content.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data; boundary=" + boundary);
var metadata = new StringContent(UploadMetadataJson(sendMigrations, keepSecrets), Encoding.UTF8, "application/json");
metadata.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = Quote("metadata") };
content.Add(metadata);
var module = new ByteArrayContent(Encoding.UTF8.GetBytes(workerSource));
module.Headers.ContentType = new MediaTypeHeaderValue("application/javascript+module");
module.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = Quote(MainModule), FileName = Quote(MainModule) };
content.Add(module);
return content;
}
// .NET writes a bare name (name=index.js) unless the value carries its own quotes. Wrangler's upload always quotes (name="index.js"), and that is the
// form Cloudflare's documentation shows, so the editor sends the same bytes rather than rely on a server accepting both.
private static string Quote(string value)
{
return "\"" + value + "\"";
}
public static CloudflareRequest Request(string method, string path, string token, HttpContent content = null, bool withScriptApiDate = false, string apiBase = null)
{
var request = new CloudflareRequest { Method = method, Url = (apiBase ?? Base) + path, Content = content };
request.Headers["Authorization"] = "Bearer " + token;
if (withScriptApiDate)
{
request.Headers[ScriptApiDateHeader] = ScriptApiDate;
}
return request;
}
public static StringContent Json(JsonNode body)
{
return new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
}
/// <summary>The value under "result" of a successful response, or null.</summary>
public static JsonNode ResultOf(string body)
{
JsonObject document = ParseObject(body);
if (document == null || !(document["success"] is JsonValue success) || !success.TryGetValue(out bool ok) || !ok)
{
return null;
}
return document["result"];
}
/// <summary>The first error code Cloudflare reported, or 0.</summary>
public static int ErrorCode(string body)
{
JsonObject document = ParseObject(body);
if (document?["errors"] is JsonArray errors && errors.Count > 0 && errors[0] is JsonObject first
&& first["code"] is JsonValue code && code.TryGetValue(out int value))
{
return value;
}
return 0;
}
private static string ErrorMessage(string body)
{
JsonObject document = ParseObject(body);
if (document?["errors"] is JsonArray errors && errors.Count > 0 && errors[0] is JsonObject first
&& first["message"] is JsonValue message && message.TryGetValue(out string text))
{
return text;
}
return null;
}
private static JsonObject ParseObject(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
try
{
return JsonNode.Parse(body) as JsonObject;
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// A sentence for a person about why a call failed: what happened and what to do next. The token is removed from anything Cloudflare echoes back, so a
/// message can be shown or logged without carrying the credential.
/// </summary>
public static string Explain(string step, CloudflareResponse response, string token)
{
if (response == null || response.Status == null)
{
string why = string.IsNullOrWhiteSpace(response?.Failure) ? string.Empty : " (" + Redact(response.Failure, token) + ")";
return "Cloudflare could not be reached while " + step + why + ". Check the internet connection and try again.";
}
int status = response.Status.Value;
int code = ErrorCode(response.Body);
if (status == 401 || status == 403 || code == 10000 || code == 9109 || code == 9106)
{
return "Cloudflare refused the token while " + step + ". Create the token from the page the Open Cloudflare button shows, which selects the permissions the relay needs, and copy it again.";
}
if (status == 429)
{
return "Cloudflare is limiting requests while " + step + ". Wait a minute and try again.";
}
if (status >= 500)
{
return "Cloudflare reported a problem of its own (status " + status + ") while " + step + ". Try again in a few minutes.";
}
string message = ErrorMessage(response.Body);
string detail = string.IsNullOrWhiteSpace(message) ? string.Empty : ": " + Bound(Redact(message, token), 200);
return "Cloudflare did not accept the request while " + step + " (status " + status + detail + ").";
}
public static string Redact(string text, string token)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(token))
{
return text;
}
return text.Replace(token, "[token]");
}
private static string Bound(string text, int max)
{
return text.Length <= max ? text : text.Substring(0, max) + "…";
}
}