diff --git a/OpenNest.Data/INestRepository.cs b/OpenNest.Data/INestRepository.cs
new file mode 100644
index 0000000..bf34476
--- /dev/null
+++ b/OpenNest.Data/INestRepository.cs
@@ -0,0 +1,31 @@
+namespace OpenNest.Data;
+
+///
+/// Storage backend for saved nests. talks to the
+/// central nest server; File mode in the desktop app keeps using SaveFileDialog and
+/// does not go through this interface.
+///
+public interface INestRepository
+{
+ /// All stored nests, newest saved first.
+ Task> ListAsync(CancellationToken cancellationToken = default);
+
+ Task GetMetadataAsync(Guid id, CancellationToken cancellationToken = default);
+
+ /// Downloaded .nest archive bytes, or null when the id does not exist.
+ Task GetFileAsync(Guid id, CancellationToken cancellationToken = default);
+
+ /// Stores a new nest; returns the server-assigned record.
+ Task UploadAsync(
+ byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default);
+
+ /// Replaces the archive and metadata of an existing id, keeping the id.
+ Task UpdateFileAsync(
+ Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default);
+
+ /// Replaces metadata only (status, made-by, comments...), leaving the archive.
+ Task UpdateMetadataAsync(
+ Guid id, NestRecord record, CancellationToken cancellationToken = default);
+
+ Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
+}
diff --git a/OpenNest.Data/NestRecord.cs b/OpenNest.Data/NestRecord.cs
new file mode 100644
index 0000000..faaadb5
--- /dev/null
+++ b/OpenNest.Data/NestRecord.cs
@@ -0,0 +1,42 @@
+using OpenNest;
+
+namespace OpenNest.Data;
+
+///
+/// Server-side metadata for one saved nest. The .nest archive itself is stored
+/// alongside; this record is what lists, filters and status tracking read.
+/// Counts are captured by the client at save time.
+///
+public sealed class NestRecord
+{
+ public Guid Id { get; set; }
+
+ public string Name { get; set; } = "";
+
+ public string Customer { get; set; } = "";
+
+ public DateTime DateCreated { get; set; }
+
+ public DateTime DateModified { get; set; }
+
+ public string Material { get; set; } = "";
+
+ public double Thickness { get; set; }
+
+ /// quote | toBeCut | hasBeenCut (camelCase on the wire, case-insensitive readers).
+ public NestStatus Status { get; set; } = NestStatus.Quote;
+
+ public int PlateCount { get; set; }
+
+ public int PartCount { get; set; }
+
+ public string Comments { get; set; } = "";
+
+ public string MadeBy { get; set; } = "";
+
+ /// Size of the stored .nest archive in bytes.
+ public long FileSize { get; set; }
+
+ /// When the server last stored the nest contents or metadata.
+ public DateTime SavedAt { get; set; }
+}
diff --git a/OpenNest.Data/NestStorageSettings.cs b/OpenNest.Data/NestStorageSettings.cs
new file mode 100644
index 0000000..f092c5d
--- /dev/null
+++ b/OpenNest.Data/NestStorageSettings.cs
@@ -0,0 +1,83 @@
+using System.Text.Json;
+
+namespace OpenNest.Data;
+
+/// Where the desktop app persists nests: local .nest files or the shared nest server.
+public enum NestStorageMode
+{
+ File,
+ Database,
+}
+
+///
+/// Storage-mode toggle and nest-server address, stored at %APPDATA%\OpenNest\storage.json.
+/// Loading never throws; a missing or corrupt file means File mode so existing installs
+/// keep the original behavior until the operator opts in.
+///
+public sealed class NestStorageSettings
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ };
+
+ public NestStorageMode Mode { get; set; } = NestStorageMode.File;
+
+ /// Base URL of the nest server, e.g. http://barge.lan:8090. No trailing slash required.
+ public string ServerUrl { get; set; } = "";
+
+ /// True when Database mode is active and a server URL is configured.
+ public bool IsDatabaseMode =>
+ Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
+
+ /// %APPDATA%\OpenNest\storage.json.
+ public static string DefaultPath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "OpenNest", "storage.json");
+
+ public static NestStorageSettings Load(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
+ return new();
+
+ try
+ {
+ var settings = JsonSerializer.Deserialize(
+ File.ReadAllText(path), JsonOptions) ?? new();
+ settings.ServerUrl = NormalizeUrl(settings.ServerUrl);
+ return settings;
+ }
+ catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
+ {
+ return new();
+ }
+ }
+
+ /// Writes camelCase JSON, creating the parent directory and retrying IO collisions.
+ public void Save(string path)
+ {
+ var json = JsonSerializer.Serialize(
+ new NestStorageSettings { Mode = Mode, ServerUrl = NormalizeUrl(ServerUrl) },
+ JsonOptions);
+ var directory = Path.GetDirectoryName(Path.GetFullPath(path));
+
+ for (var attempt = 0; attempt < 3; attempt++)
+ {
+ try
+ {
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+ File.WriteAllText(path, json);
+ return;
+ }
+ catch (IOException) when (attempt < 2)
+ {
+ Thread.Sleep(100);
+ }
+ }
+ }
+
+ private static string NormalizeUrl(string? url) => (url ?? "").Trim().TrimEnd('/');
+}
diff --git a/OpenNest.Data/RemoteNestRepository.cs b/OpenNest.Data/RemoteNestRepository.cs
new file mode 100644
index 0000000..b893180
--- /dev/null
+++ b/OpenNest.Data/RemoteNestRepository.cs
@@ -0,0 +1,159 @@
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenNest.Data;
+
+///
+/// Talks to the central OpenNest nest server over HTTP. Uploads are multipart:
+/// a "metadata" JSON part and a "file" part holding the .nest archive.
+/// Connection and HTTP failures surface as HttpRequestException/IOException to the caller.
+///
+public sealed class RemoteNestRepository : INestRepository, IDisposable
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
+ };
+
+ private readonly HttpClient _httpClient;
+ private readonly bool _ownsClient;
+ private readonly Uri _baseUri;
+
+ public RemoteNestRepository(string baseUrl)
+ : this(new HttpClient(), baseUrl)
+ {
+ _ownsClient = true;
+ }
+
+ /// For tests and hosts that pool HttpClient instances.
+ public RemoteNestRepository(HttpClient httpClient, string baseUrl)
+ {
+ _httpClient = httpClient;
+ _ownsClient = false;
+ var url = (baseUrl ?? "").Trim().TrimEnd('/');
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var baseUri)
+ || (baseUri.Scheme != Uri.UriSchemeHttp && baseUri.Scheme != Uri.UriSchemeHttps))
+ {
+ throw new ArgumentException($"Invalid nest server URL: '{baseUrl}'", nameof(baseUrl));
+ }
+ // Keep the trailing slash so relative combines replace no path segment.
+ _baseUri = new Uri(baseUri.AbsoluteUri.TrimEnd('/') + "/");
+ }
+
+ private Uri Url(string relative) => new(_baseUri, relative);
+
+ public async Task> ListAsync(CancellationToken cancellationToken = default)
+ {
+ using var response = await _httpClient.GetAsync(Url("api/nests"), cancellationToken);
+ await EnsureSuccess(response, "list nests", cancellationToken);
+ var items = await response.Content
+ .ReadFromJsonAsync>(JsonOptions, cancellationToken);
+ return items ?? new List();
+ }
+
+ public async Task GetMetadataAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken);
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ return null;
+ await EnsureSuccess(response, $"get nest {id}", cancellationToken);
+ return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken);
+ }
+
+ public async Task GetFileAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ using var response = await _httpClient.GetAsync(Url($"api/nests/{id}/file"), cancellationToken);
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ return null;
+ await EnsureSuccess(response, $"download nest {id}", cancellationToken);
+ return await response.Content.ReadAsByteArrayAsync(cancellationToken);
+ }
+
+ public async Task UploadAsync(
+ byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
+ {
+ using var content = BuildMultipart(nestFile, record);
+ using var response = await _httpClient.PostAsync(Url("api/nests"), content, cancellationToken);
+ await EnsureSuccess(response, $"upload nest '{record.Name}'", cancellationToken);
+ return await ReadRecord(response, cancellationToken);
+ }
+
+ public async Task UpdateFileAsync(
+ Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
+ {
+ using var content = BuildMultipart(nestFile, record);
+ using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/file"), content, cancellationToken);
+ await EnsureSuccess(response, $"update nest {id}", cancellationToken);
+ return await ReadRecord(response, cancellationToken);
+ }
+
+ public async Task UpdateMetadataAsync(
+ Guid id, NestRecord record, CancellationToken cancellationToken = default)
+ {
+ using var content = new StringContent(
+ JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
+ using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/metadata"), content, cancellationToken);
+ await EnsureSuccess(response, $"update nest metadata {id}", cancellationToken);
+ return await ReadRecord(response, cancellationToken);
+ }
+
+ public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ using var response = await _httpClient.DeleteAsync(Url($"api/nests/{id}"), cancellationToken);
+ await EnsureSuccess(response, $"delete nest {id}", cancellationToken);
+ }
+
+ public void Dispose()
+ {
+ if (_ownsClient)
+ _httpClient.Dispose();
+ }
+
+ private static MultipartFormDataContent BuildMultipart(byte[] nestFile, NestRecord record)
+ {
+ var content = new MultipartFormDataContent();
+ var metadata = new StringContent(
+ JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
+ content.Add(metadata, "metadata");
+ var file = new ByteArrayContent(nestFile);
+ file.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
+ content.Add(file, "file", record.Name.Length > 0 ? $"{record.Name}.nest" : "nest.nest");
+ return content;
+ }
+
+ private static async Task ReadRecord(
+ HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ var record = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken);
+ return record ?? throw new IOException("Nest server returned an empty record.");
+ }
+
+ private static async Task EnsureSuccess(
+ HttpResponseMessage response, string action, CancellationToken cancellationToken)
+ {
+ if (response.IsSuccessStatusCode)
+ return;
+
+ var detail = "";
+ try
+ {
+ detail = await response.Content.ReadAsStringAsync(cancellationToken);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidOperationException)
+ {
+ // The status code is the useful part; the body is best-effort.
+ }
+
+ if (detail.Length > 200)
+ detail = detail[..200] + "…";
+ throw new IOException(
+ $"Could not {action} on the nest server: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {detail}".TrimEnd());
+ }
+}
diff --git a/OpenNest.Tests/Data/NestStorageTests.cs b/OpenNest.Tests/Data/NestStorageTests.cs
new file mode 100644
index 0000000..606c024
--- /dev/null
+++ b/OpenNest.Tests/Data/NestStorageTests.cs
@@ -0,0 +1,223 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+public class NestStorageSettingsTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(
+ Path.GetTempPath(), $"opennest-storage-{Guid.NewGuid():N}");
+
+ private string FilePath => Path.Combine(_directory, "storage.json");
+
+ [Fact]
+ public void Load_MissingFile_DefaultsToFileMode()
+ {
+ var settings = NestStorageSettings.Load(FilePath);
+
+ Assert.Equal(NestStorageMode.File, settings.Mode);
+ Assert.Equal("", settings.ServerUrl);
+ Assert.False(settings.IsDatabaseMode);
+ }
+
+ [Fact]
+ public void Load_CorruptFile_DefaultsToFileModeWithoutThrowing()
+ {
+ Directory.CreateDirectory(_directory);
+ File.WriteAllText(FilePath, "{ not json");
+
+ var settings = NestStorageSettings.Load(FilePath);
+
+ Assert.Equal(NestStorageMode.File, settings.Mode);
+ }
+
+ [Fact]
+ public void SaveThenLoad_DatabaseMode_RoundTrips()
+ {
+ NestStorageSettings.Load(FilePath)
+ .Save(FilePath); // ensure directory exists pattern
+ var settings = new NestStorageSettings
+ {
+ Mode = NestStorageMode.Database,
+ ServerUrl = "http://barge.lan:8090/",
+ };
+
+ settings.Save(FilePath);
+ var loaded = NestStorageSettings.Load(FilePath);
+
+ Assert.Equal(NestStorageMode.Database, loaded.Mode);
+ Assert.Equal("http://barge.lan:8090", loaded.ServerUrl);
+ Assert.True(loaded.IsDatabaseMode);
+ }
+
+ [Fact]
+ public void DatabaseMode_WithoutServerUrl_IsNotActive()
+ {
+ var settings = new NestStorageSettings { Mode = NestStorageMode.Database };
+ Assert.False(settings.IsDatabaseMode);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_directory))
+ Directory.Delete(_directory, recursive: true);
+ }
+}
+
+/// Records requests and replies with canned responses.
+internal sealed class StubHandler : HttpMessageHandler
+{
+ private readonly Func _responder;
+
+ public List Requests { get; } = new();
+ public List RequestBodies { get; } = new();
+
+ public StubHandler(Func responder) =>
+ _responder = responder;
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Requests.Add(request);
+ RequestBodies.Add(request.Content is null
+ ? ""
+ : await request.Content.ReadAsStringAsync(cancellationToken));
+ return _responder(request);
+ }
+
+ public static HttpResponseMessage Json(object payload, HttpStatusCode code = HttpStatusCode.OK) =>
+ new(code)
+ {
+ Content = new StringContent(
+ JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
+ };
+}
+
+public class RemoteNestRepositoryTests
+{
+ private static NestRecord SampleRecord(Guid? id = null) => new()
+ {
+ Id = id ?? Guid.NewGuid(),
+ Name = "Job 1",
+ Customer = "Cozy Cat",
+ Material = "Aluminum",
+ Thickness = 0.25,
+ Status = NestStatus.ToBeCut,
+ PlateCount = 2,
+ PartCount = 17,
+ Comments = "rush",
+ MadeBy = "AJ",
+ FileSize = 1234,
+ SavedAt = new DateTime(2026, 9, 29, 12, 0, 0),
+ };
+
+ [Fact]
+ public void Constructor_RejectsInvalidUrl()
+ {
+ Assert.Throws(() => new RemoteNestRepository("not a url"));
+ Assert.Throws(() => new RemoteNestRepository("ftp://host"));
+ }
+
+ [Fact]
+ public async Task ListAsync_DeserializesCamelCaseRecords()
+ {
+ var handler = new StubHandler(_ => StubHandler.Json(new[] { SampleRecord() }));
+ using var repo = new RemoteNestRepository(new HttpClient(handler), "http://server:8090/");
+
+ var records = await repo.ListAsync();
+
+ Assert.Single(records);
+ Assert.Equal("Cozy Cat", records[0].Customer);
+ Assert.Equal(NestStatus.ToBeCut, records[0].Status);
+ Assert.Contains("/api/nests", handler.Requests[0].RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task UploadAsync_PostsMultipartWithMetadataAndFile()
+ {
+ var record = SampleRecord();
+ var handler = new StubHandler(_ => StubHandler.Json(record));
+ using var repo = new RemoteNestRepository(new HttpClient(handler), "http://server:8090");
+
+ var result = await repo.UploadAsync(new byte[] { 1, 2, 3 }, record);
+
+ Assert.Equal(record.Id, result.Id);
+ var request = handler.Requests[0];
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.EndsWith("/api/nests", request.RequestUri!.ToString());
+ Assert.StartsWith("multipart/form-data", request.Content!.Headers.ContentType!.ToString());
+ Assert.Contains("metadata", handler.RequestBodies[0]);
+ Assert.Contains("toBeCut", handler.RequestBodies[0]);
+ Assert.Contains("filename=\"Job 1.nest\"", handler.RequestBodies[0]);
+ }
+
+ [Fact]
+ public async Task UpdateFileAsync_PutsMultipartToIdFileRoute()
+ {
+ var id = Guid.NewGuid();
+ var record = SampleRecord(id);
+ var handler = new StubHandler(_ => StubHandler.Json(record));
+ using var repo = new RemoteNestRepository(new HttpClient(handler), "http://server:8090");
+
+ await repo.UpdateFileAsync(id, new byte[] { 9 }, record);
+
+ var request = handler.Requests[0];
+ Assert.Equal(HttpMethod.Put, request.Method);
+ Assert.EndsWith($"/api/nests/{id}/file", request.RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task GetFileAsync_NullOnNotFound_BytesOtherwise()
+ {
+ var missing = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ using var missingRepo = new RemoteNestRepository(new HttpClient(missing), "http://s");
+ Assert.Null(await missingRepo.GetFileAsync(Guid.NewGuid()));
+
+ var found = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(new byte[] { 4, 5, 6 }),
+ });
+ using var foundRepo = new RemoteNestRepository(new HttpClient(found), "http://s");
+ Assert.Equal(new byte[] { 4, 5, 6 }, await foundRepo.GetFileAsync(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public async Task GetMetadataAsync_NullOnNotFound()
+ {
+ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
+
+ Assert.Null(await repo.GetMetadataAsync(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public async Task UpdateMetadataAsync_PutsJsonToMetadataRoute()
+ {
+ var id = Guid.NewGuid();
+ var record = SampleRecord(id);
+ var handler = new StubHandler(_ => StubHandler.Json(record));
+ using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
+
+ await repo.UpdateMetadataAsync(id, record);
+
+ Assert.Equal(HttpMethod.Put, handler.Requests[0].Method);
+ Assert.EndsWith($"/api/nests/{id}/metadata", handler.Requests[0].RequestUri!.ToString());
+ Assert.Equal("application/json", handler.Requests[0].Content!.Headers.ContentType!.MediaType);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_ThrowsWithStatusDetailOnError()
+ {
+ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)
+ {
+ Content = new StringContent("disk on fire"),
+ });
+ using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
+
+ var ex = await Assert.ThrowsAsync(() => repo.DeleteAsync(Guid.NewGuid()));
+ Assert.Contains("500", ex.Message);
+ Assert.Contains("disk on fire", ex.Message);
+ }
+}