// Minimal .NET 8+ console client for an explicitly enabled ISOLATED pilot. // Use this file alone as Program.cs; it is separate from CatalogueClient.cs. // Run: dotnet run -- release.json persisted-despatch-key // Set VAPETAX_API_URL and VAPETAX_API_KEY in private server configuration. // Persist the exact body file AND key before first send, including across restarts. // This example never generates another despatch/key in response to a timeout. // Integrate returned release IDs/evidence with the connector's durable journal. using System.Net; using System.Net.Http.Headers; using System.Text.Json; if (args.Length != 2) throw new ArgumentException("Usage: release.json persisted-idempotency-key"); var baseUrl = Environment.GetEnvironmentVariable("VAPETAX_API_URL") ?? throw new InvalidOperationException("Configure VAPETAX_API_URL."); var apiKey = Environment.GetEnvironmentVariable("VAPETAX_API_KEY") ?? throw new InvalidOperationException("Configure VAPETAX_API_KEY securely."); var origin = new Uri(baseUrl.TrimEnd('/') + "/"); if ((!origin.IsLoopback && origin.Scheme != Uri.UriSchemeHttps) || (origin.Scheme != Uri.UriSchemeHttp && origin.Scheme != Uri.UriSchemeHttps) || origin.AbsolutePath != "/" || !string.IsNullOrEmpty(origin.Query) || !string.IsNullOrEmpty(origin.Fragment) || !string.IsNullOrEmpty(origin.UserInfo)) throw new ArgumentException("Use an HTTPS API origin, or isolated loopback HTTP."); var key = args[1]; if (key.Length is < 1 or > 128 || key.Any(c => c < '!' || c > '~')) throw new ArgumentException("The key must contain 1–128 printable non-space ASCII characters."); var payload = await File.ReadAllBytesAsync(args[0]); if (payload.Length > 1_048_576) throw new ArgumentException("Release exceeds 1 MiB."); using var input = JsonDocument.Parse(payload); var externalId = input.RootElement.GetProperty("externalDespatchId").GetString() ?? throw new ArgumentException("externalDespatchId is required."); using var handler = new HttpClientHandler { AllowAutoRedirect = false }; using var client = new HttpClient(handler) { BaseAddress = origin, Timeout = TimeSpan.FromSeconds(30) }; client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); using (var capabilities = JsonDocument.Parse((await Send(HttpMethod.Get, "integrations/v1/capabilities")).Body)) { var c = capabilities.RootElement; var scopes = c.GetProperty("operations").EnumerateArray().Select(s => s.GetString()).ToHashSet(); if (c.GetProperty("environment").GetString() == "LIVE" || !c.GetProperty("releaseAcceptanceEnabled").GetBoolean()) throw new InvalidOperationException("This sample requires an enabled isolated pilot; LIVE releases are held."); if (!new[] { "releases:validate", "releases:write", "releases:read" }.All(scopes.Contains)) throw new InvalidOperationException("The key requires releases:validate, releases:write and releases:read."); } // Recover by business identity first. Only preview a new release: a previous // successful release is no longer eligible for a new validation of the stock. var lookupPath = "integrations/v1/releases?externalDespatchId=" + Uri.EscapeDataString(externalId); using (var prior = JsonDocument.Parse((await Send(HttpMethod.Get, lookupPath)).Body)) { if (prior.RootElement.GetProperty("items").GetArrayLength() == 0) await Send(HttpMethod.Post, "integrations/v1/release-validations", payload); } // Always submit the original key/body, even after lookup finds a record, so the // server checks canonical equality. A changed request must return conflict. var accepted = await Send(HttpMethod.Post, "integrations/v1/releases", payload, key); using var acceptedJson = JsonDocument.Parse(accepted.Body); var releaseId = acceptedJson.RootElement.GetProperty("id").GetString() ?? throw new IOException("Missing release ID."); Console.WriteLine(accepted.Body); // Persist this ID/receipt; never log API headers. var current = accepted; for (var poll = 0; poll < 30; poll++) { using var state = JsonDocument.Parse(current.Body); var row = state.RootElement; var environment = row.GetProperty("environment").GetString(); var provider = row.GetProperty("notification").GetProperty("status").GetString(); Console.WriteLine($"Release {releaseId}: local={row.GetProperty("releaseStatus").GetString()}, accounting={row.GetProperty("accounting").GetProperty("status").GetString()}, provider={provider}, environment={environment}"); if (provider == "SIMULATED") { Console.WriteLine("Simulation only. No live SICPA confirmation."); return; } if (provider == "CONFIRMED") { if (environment != "SANDBOX") throw new IOException("Unexpected confirmation environment; inspect the retained evidence."); Console.WriteLine("SICPA SANDBOX confirmation only. This is not a live notification or duty payment."); return; } if (provider != "QUEUED") throw new IOException("Provider outcome needs operator attention. Keep this release ID and do not resubmit goods under another identity."); var delay = current.RetryAfter ?? TimeSpan.FromSeconds(Math.Min(30, 5 + poll * 5)); await Task.Delay(TimeSpan.FromSeconds(Math.Clamp(delay.TotalSeconds, 1, 60))); current = await Send(HttpMethod.Get, "integrations/v1/releases/" + Uri.EscapeDataString(releaseId)); } throw new IOException("Polling deadline reached. Keep the accepted release ID and resume GET status later; do not create another release."); async Task<(string Body, TimeSpan? RetryAfter)> Send(HttpMethod method, string path, byte[]? body = null, string? idempotencyKey = null) { for (var attempt = 0; attempt < 5; attempt++) { var delay = TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, attempt + 1))); using var request = new HttpRequestMessage(method, path); if (body != null) { request.Content = new ByteArrayContent(body); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); } if (idempotencyKey != null) request.Headers.Add("Idempotency-Key", idempotencyKey); try { using var response = await client.SendAsync(request); var text = await response.Content.ReadAsStringAsync(); var retryAfter = response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow); if (response.IsSuccessStatusCode) return (text, retryAfter); if (response.StatusCode != HttpStatusCode.TooManyRequests && (int)response.StatusCode < 500) throw new InvalidOperationException($"VapeTax rejected the request ({(int)response.StatusCode}): {text}"); delay = retryAfter ?? (response.StatusCode == HttpStatusCode.TooManyRequests ? TimeSpan.FromSeconds(60) : delay); } catch (HttpRequestException) when (attempt < 4) { } catch (TaskCanceledException) when (attempt < 4) { } if (attempt < 4) await Task.Delay(TimeSpan.FromSeconds(Math.Clamp(delay.TotalSeconds, 1, 60)) + TimeSpan.FromMilliseconds(Random.Shared.Next(100, 500))); } throw new IOException("Outcome unresolved. Preserve the exact body/key and recover by externalDespatchId. Never issue a replacement despatch to resolve an uncertain outcome."); }