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);
}
}