using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using ClaudeDo.Worker.Online.Interfaces; namespace ClaudeDo.Worker.Online; public sealed class OnlineInboxApiClient : IOnlineInboxApi { internal const string MissingRoleMessage = "Account has no access (missing 'user' role in Zitadel). " + "Grant the 'user' role for this account in the ClaudeDo project, then sign in again."; private readonly HttpClient _http; private readonly IOnlineAuthProvider _auth; public OnlineInboxApiClient(HttpClient http, IOnlineAuthProvider auth) { _http = http; _auth = auth; } /// /// Validates that is HTTPS or a loopback address. /// Throws for non-HTTPS non-loopback URLs. /// public static void ValidateBaseUrl(string baseUrl) { if (string.IsNullOrWhiteSpace(baseUrl)) throw new InvalidOperationException("online_inbox.api_base_url is not configured."); var uri = new Uri(baseUrl, UriKind.Absolute); if (uri.Scheme != "https" && !uri.IsLoopback) throw new InvalidOperationException( $"online_inbox.api_base_url must be HTTPS or loopback. Got: {baseUrl}"); } public async Task PutListsAsync(IReadOnlyList lists, CancellationToken ct = default) { using var resp = await SendAsync(HttpMethod.Put, "lists", lists, ct); } public async Task> GetUnimportedTasksAsync(CancellationToken ct = default) { using var resp = await SendAsync(HttpMethod.Get, "tasks?imported=false", null, ct); var result = await resp.Content.ReadFromJsonAsync>(ct); return result ?? []; } public async Task MarkImportedAsync(string id, CancellationToken ct = default) { using var resp = await SendAsync(HttpMethod.Post, $"tasks/{Uri.EscapeDataString(id)}/imported", null, ct); } public async Task PutMirrorAsync(IReadOnlyList tasks, CancellationToken ct = default) { using var resp = await SendAsync(HttpMethod.Put, "tasks/mirror", tasks, ct); } /// /// Sends an authenticated request. On a 401, forces a fresh (role-bearing) token via the /// refresh-token grant and retries once; if a fresh token is still rejected, throws an /// with . /// private async Task SendAsync( HttpMethod method, string path, object? body, CancellationToken ct) { var resp = await SendOnceAsync(method, path, body, forceRefresh: false, ct); if (resp.StatusCode == HttpStatusCode.Unauthorized) { resp.Dispose(); resp = await SendOnceAsync(method, path, body, forceRefresh: true, ct); } if (resp.StatusCode == HttpStatusCode.Unauthorized) { resp.Dispose(); throw new OnlineInboxException(401, MissingRoleMessage); } if (!resp.IsSuccessStatusCode) { var status = (int)resp.StatusCode; var errBody = await resp.Content.ReadAsStringAsync(ct); resp.Dispose(); throw new OnlineInboxException(status, $"Online Inbox API error {status}: {errBody}"); } return resp; } private async Task SendOnceAsync( HttpMethod method, string path, object? body, bool forceRefresh, CancellationToken ct) { var token = await _auth.GetAccessTokenAsync(forceRefresh, ct); using var req = new HttpRequestMessage(method, path); if (token is not null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); if (body is not null) req.Content = JsonContent.Create(body); return await _http.SendAsync(req, ct); } }