// Aere.Cloud - the official .NET client for the Aere Cloud API (Aere Network, chain 2800).
//
// Same routes and semantics as @aere/cloud (JS), aere-cloud (Python), aere-cloud (Java) and the Go client, 1.7.0. No dependency beyond
// the .NET base library. JSON routes return the server's JSON as a JsonElement, so the integrator binds it to whatever types they
// already use; the two routes that return FILES (the audit export and the check of a kept export) return typed results with the
// digest computed locally from the bytes received. Non-2xx answers are thrown as AereCloudException (HTTP status, server body, path).
//
// Nota interna: comentariile de implementare sunt in romana fara diacritice, ca in celelalte SDK-uri ale casei; documentatia publica
// (comentariile XML ale membrilor publici) e in engleza.
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Aere.Cloud;
/// A non-2xx answer from Aere Cloud. 0 means the request never got an answer.
public sealed class AereCloudException : Exception
{
/// HTTP status (0 when there was no answer).
public int Status { get; }
/// The server's body, as text.
public string Body { get; }
/// The API path that failed.
public string Path { get; }
/// Creates the exception.
public AereCloudException(int status, string body, string path)
: base($"Aere Cloud {status} on {path}: {(string.IsNullOrEmpty(body) ? "request failed" : body)}")
{
Status = status; Body = body; Path = path;
}
}
/// Options for .
public sealed record PerimeterOptions(IReadOnlyList? Hosts = null, bool? Discover = null, bool Attest = false);
/// An audit day as a file. For jsonl, says whether the sha256 of the bytes RECEIVED equals the stated
/// digest (computed here, not taken on trust); for cef it is null, because the digest is the one of the source file.
public sealed record AuditExport(string? Day, string Format, string? Digest, byte[] Bytes, int Entries, bool? Verified)
{
/// The bytes as UTF-8 text.
public string Text => Encoding.UTF8.GetString(Bytes);
}
/// Does an export you KEPT still match the day we hold ("held"), or the tombstone a deleted day left ("expired")?
public sealed record KeptCheck(string Day, string State, string Digest, string Local, bool Matches, string? DeletedAt);
/// One Aere Cloud gateway with one API key (ak2800.<address>.<secret>).
public sealed class AereCloudClient : IDisposable
{
/// Version of this client, sent in the User-Agent (the edge refuses generic library agents on every route).
public const string Version = "1.7.0";
/// The production gateway.
public const string DefaultBaseUrl = "https://cloud.aere.network/v1";
private readonly string _apiKey;
private readonly string _baseUrl;
private readonly HttpClient _http;
/// A client for (default: production). The API key is required. A test may pass its own
/// ; the default one does NOT follow redirects, so the key never travels to a host named in a Location.
public AereCloudClient(string apiKey, string? baseUrl = null, HttpMessageHandler? handler = null)
{
if (string.IsNullOrEmpty(apiKey)) throw new ArgumentException("Aere.Cloud: apiKey is required (ak2800..)", nameof(apiKey));
_apiKey = apiKey;
_baseUrl = (baseUrl ?? DefaultBaseUrl).TrimEnd('/');
// fara redirectari urmate: un 3xx e un raspuns (AereCloudException cu statusul lui), nu un drum spre alta gazda care ar primi cheia
_http = new HttpClient(handler ?? new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(30) };
}
///
public void Dispose() => _http.Dispose();
private sealed record Raspuns(int Status, byte[] Body, Func Antet);
private async Task DoAsync(HttpMethod method, string path, object? body, bool auth, CancellationToken ct)
{
using var req = new HttpRequestMessage(method, _baseUrl + path);
req.Headers.UserAgent.ParseAdd("aere-cloud-csharp/" + Version);
if (auth) req.Headers.Add("x-api-key", _apiKey);
if (body is not null)
{
req.Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(body));
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
}
HttpResponseMessage r;
try { r = await _http.SendAsync(req, ct).ConfigureAwait(false); }
catch (HttpRequestException e) { throw new AereCloudException(0, e.Message, path); } // mesajul de retea nu poarta cheia (e in antet)
using (r)
{
var b = await r.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
var h = r.Headers; var hc = r.Content.Headers;
string? Antet(string n) => h.TryGetValues(n, out var v) ? v.FirstOrDefault() : hc.TryGetValues(n, out var w) ? w.FirstOrDefault() : null;
return new Raspuns((int)r.StatusCode, b, Antet);
}
}
private async Task JsonAsync(HttpMethod method, string path, object? body, bool auth, CancellationToken ct)
{
var r = await DoAsync(method, path, body, auth, ct).ConfigureAwait(false);
if (r.Status < 200 || r.Status >= 300) throw new AereCloudException(r.Status, Encoding.UTF8.GetString(r.Body), path);
using var d = JsonDocument.Parse(r.Body.Length == 0 ? "null"u8.ToArray() : r.Body);
return d.RootElement.Clone();
}
private static string Esc(string s) => Uri.EscapeDataString(s);
private static string Sha256Hex(byte[] b) => "0x" + Convert.ToHexString(SHA256.HashData(b)).ToLowerInvariant();
// ---- health, account, JSON-RPC ----
/// GET /health (no key).
public Task HealthAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/health", null, false, ct);
/// GET /account.
public Task AccountAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/account", null, true, ct);
/// One JSON-RPC call; returns the whole JSON-RPC envelope.
public Task RpcAsync(string method, IReadOnlyList