Optional, opt-in (online_inbox.enabled, default false → zero network). Worker-side reconcile loop: pull web-created tasks down as Idle, push the list catalog and the Idle backlog mirror up. Auth behind IOnlineAuthProvider (StaticTokenAuthProvider default; ZitadelAuthProvider stubbed for Phase 2). DPAPI refresh-token store. 35 tests, no real network/Zitadel/Claude. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
85 lines
3.2 KiB
C#
85 lines
3.2 KiB
C#
using System.Net.Http.Json;
|
|
using ClaudeDo.Worker.Online.Interfaces;
|
|
|
|
namespace ClaudeDo.Worker.Online;
|
|
|
|
public sealed class OnlineInboxApiClient : IOnlineInboxApi
|
|
{
|
|
private readonly HttpClient _http;
|
|
private readonly IOnlineAuthProvider _auth;
|
|
|
|
public OnlineInboxApiClient(HttpClient http, IOnlineAuthProvider auth)
|
|
{
|
|
_http = http;
|
|
_auth = auth;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that <paramref name="baseUrl"/> is HTTPS or a loopback address.
|
|
/// Throws <see cref="InvalidOperationException"/> for non-HTTPS non-loopback URLs.
|
|
/// </summary>
|
|
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<RemoteList> lists, CancellationToken ct = default)
|
|
{
|
|
using var req = await BuildAsync(HttpMethod.Put, "/lists", lists, ct);
|
|
using var resp = await _http.SendAsync(req, ct);
|
|
await EnsureSuccessAsync(resp, ct);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(CancellationToken ct = default)
|
|
{
|
|
using var req = await BuildAsync(HttpMethod.Get, "/tasks?imported=false", null, ct);
|
|
using var resp = await _http.SendAsync(req, ct);
|
|
await EnsureSuccessAsync(resp, ct);
|
|
var result = await resp.Content.ReadFromJsonAsync<List<RemoteTask>>(ct);
|
|
return result ?? [];
|
|
}
|
|
|
|
public async Task MarkImportedAsync(string id, CancellationToken ct = default)
|
|
{
|
|
using var req = await BuildAsync(HttpMethod.Post, $"/tasks/{Uri.EscapeDataString(id)}/imported", null, ct);
|
|
using var resp = await _http.SendAsync(req, ct);
|
|
await EnsureSuccessAsync(resp, ct);
|
|
}
|
|
|
|
public async Task PutMirrorAsync(IReadOnlyList<MirrorTask> tasks, CancellationToken ct = default)
|
|
{
|
|
using var req = await BuildAsync(HttpMethod.Put, "/tasks/mirror", tasks, ct);
|
|
using var resp = await _http.SendAsync(req, ct);
|
|
await EnsureSuccessAsync(resp, ct);
|
|
}
|
|
|
|
private async Task<HttpRequestMessage> BuildAsync(
|
|
HttpMethod method,
|
|
string path,
|
|
object? body,
|
|
CancellationToken ct)
|
|
{
|
|
var token = await _auth.GetAccessTokenAsync(ct);
|
|
var req = new HttpRequestMessage(method, path);
|
|
if (token is not null)
|
|
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
|
if (body is not null)
|
|
req.Content = JsonContent.Create(body);
|
|
return req;
|
|
}
|
|
|
|
private static async Task EnsureSuccessAsync(HttpResponseMessage resp, CancellationToken ct)
|
|
{
|
|
if (resp.IsSuccessStatusCode) return;
|
|
var body = await resp.Content.ReadAsStringAsync(ct);
|
|
throw new OnlineInboxException((int)resp.StatusCode,
|
|
$"Online Inbox API error {(int)resp.StatusCode}: {body}");
|
|
}
|
|
}
|