diff --git a/CLAUDE.md b/CLAUDE.md
index e72c413..d083ac7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -82,6 +82,8 @@ sources (Tasmota/HA/MQTT/manual/CSV)
**Time & DST (SDD §10):** store UTC everywhere; bucket and display in the instance timezone (default `Europe/Berlin`). "Daily cost" boundaries are local-midnight — use `time_bucket(..., 'Europe/Berlin')`.
+**In-app update (`UpdateRunner`):** the dashboard shows a banner when a newer tag exists (`UpdateCheckService`, cached, never blocks a render). Triggering an update is **off by default** and needs three independent conditions: `MeterVault__AllowInAppUpdate`, at least one configured API key, and a caller presenting one (constant-time compare). `AllowAnonymousApi` deliberately cannot reach it — the updater builds whatever is on the branch and the LXC runs the app as root, so this is root-equivalent. Launches detached via `systemd-run` because the update restarts the service. Treat any change here as security-critical; `UpdateRunnerTests` pins the gate.
+
**Secrets (SDD §6.4):** broker/HA tokens are **never** stored in DB plaintext. Two forms, chosen per connector in the admin UI: a *reference* (`token_env`/`password_env` naming an env var or Docker secret path) resolved at runtime, or *encrypted at rest* (`token_enc`/`password_enc`) via `SecretProtector` over the ASP.NET Core data-protection key ring. Exactly one survives a save; `EndpointSecret.Resolve` is the single resolution path (encrypted wins). The key ring lives outside the app directory (`MeterVault__DataProtectionKeyPath`, default `/var/lib/metervault/keys`) because the LXC updater republishes `/opt/metervault`. `ExportService` drops `*_enc` values — they are bound to the originating key ring.
## Reference-data behaviours the code must reproduce (from `sampledata/`)
diff --git a/README.md b/README.md
index 3fb7441..9726db0 100644
--- a/README.md
+++ b/README.md
@@ -74,6 +74,7 @@ Configuration is via environment variables (`Section__Key` double-underscore map
| `MeterVault__SeedReferenceData` | `true` to load the bundled demo dataset on first start (idempotent) |
| `MeterVault__DataProtectionKeyPath` | Where the key ring for UI-entered connector secrets lives (default `/var/lib/metervault/keys`) |
| `MeterVault__UpdateCheckEnabled` | `false` to stop the dashboard checking for a newer release |
+| `MeterVault__AllowInAppUpdate` | `true` to allow updates triggered from the UI/API — grants root-equivalent access to anyone with an API key; see below |
| `MeterVault__UpdateCheckUrl` | Tag listing consulted by that check (repoint at a fork; blank also disables it) |
The REST API is **closed by default**: with no `ApiKeys` configured and `AllowAnonymousApi` off, it
@@ -82,15 +83,34 @@ returns 401. Set at least one API key (or open it explicitly for a trusted netwo
> **The web UI has no authentication.** There is no login: anything that can reach the port can read
> and change everything, including connectors and their stored secrets. Put it behind a reverse proxy
> with auth (Authelia, Traefik forward-auth, …) — `MeterVault__ReverseProxyTrust` then honours the
-> user header — or keep it on a trusted network. This is why the dashboard reports that an update is
-> available but does not offer to apply it: with the app running as root in the LXC, a one-click
-> update would be an unauthenticated path to arbitrary code execution.
+> user header — or keep it on a trusted network.
The dashboard compares the running build against the newest tag in the source repository and shows a
banner when it is behind. That is a plain GET of a public tag list — nothing about the instance is
sent — cached for six hours, and it never blocks or fails a page render. Turn it off with
`MeterVault__UpdateCheckEnabled=false`.
+### Updating from the UI (opt-in)
+
+`MeterVault__AllowInAppUpdate=true` adds an **Update now** button to that banner, and a
+`POST /api/v1/system/update` endpoint for scripting it from Home Assistant or `curl`:
+
+```bash
+curl -X POST http://metervault:8760/api/v1/system/update -H "X-Api-Key: $METERVAULT_API_KEY"
+```
+
+Both pull the latest source, rebuild, and restart the service — a few minutes during which MeterVault
+is unavailable. Readings are untouched; ingestion resumes on restart. LXC only: containers are
+replaced by pulling a new image, and the endpoint reports that rather than pretending.
+
+> **Understand what this grants before enabling it.** The updater builds whatever is on the branch,
+> and the LXC runs MeterVault as **root** — so a valid API key becomes arbitrary code execution on
+> that host. Three things must all hold before anything runs: the opt-in above, at least one
+> configured API key, and a caller presenting one. In particular `MeterVault__AllowAnonymousApi` can
+> **never** reach it — opening reads must not open root — and the UI button asks for the key every
+> time rather than remembering it, because the UI itself has no login. Leave this off unless the UI
+> is behind an authenticating proxy or on a network you fully trust.
+
Secrets (broker/HA tokens) are **never** stored in the database as plaintext. Each connector picks
one of two forms: the *name* of an environment variable, resolved at runtime, or the secret typed
into the admin UI and encrypted at rest under the data-protection key ring. Either way a `pg_dump`
diff --git a/src/App/Api/ApiEndpoints.cs b/src/App/Api/ApiEndpoints.cs
index bfb88bb..07c18f5 100644
--- a/src/App/Api/ApiEndpoints.cs
+++ b/src/App/Api/ApiEndpoints.cs
@@ -4,6 +4,7 @@ using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Ingestion;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Persistence;
+using MeterVault.Infrastructure.Update;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.App.Api;
@@ -58,6 +59,35 @@ public static class ApiEndpoints
return Results.Ok(new IngestResult(written, updated, rejected, ignored));
}).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push.");
+ // Not on the shared group filter: that one lets requests through anonymously when
+ // AllowAnonymousApi is set, and opening reads must never open a root shell. This checks the
+ // presented key itself, in constant time, on top of the group filter.
+ api.MapPost("/system/update", async (HttpContext http, UpdateRunner runner, CancellationToken ct) =>
+ {
+ if (runner.Availability is not UpdateAvailability.Allowed)
+ {
+ return Results.Problem(
+ statusCode: StatusCodes.Status409Conflict,
+ title: runner.Availability switch
+ {
+ UpdateAvailability.NotEnabled => "In-app update is disabled. Set MeterVault__AllowInAppUpdate=true.",
+ UpdateAvailability.NoApiKeyConfigured => "In-app update requires at least one configured API key.",
+ _ => "This install has no in-place update mechanism (containers are replaced, not updated).",
+ });
+ }
+
+ if (!runner.IsAuthorised(http.Request.Headers[ApiKeyFilter.HeaderName].ToString()))
+ {
+ return Results.Problem(statusCode: StatusCodes.Status401Unauthorized,
+ title: "A valid API key is required to start an update.");
+ }
+
+ var launch = await runner.LaunchAsync(ct);
+ return launch.Started
+ ? Results.Accepted(value: new { message = launch.Message })
+ : Results.Problem(statusCode: StatusCodes.Status500InternalServerError, title: launch.Message);
+ }).WithSummary("Start an in-place update (LXC only; requires opt-in and a valid API key).");
+
api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) =>
Results.Ok(await db.Meters.AsNoTracking()
.Select(m => new { m.Id, m.Name, m.EnergyTypeId, Mode = m.Mode.ToString(), m.Unit, m.IsActive })
diff --git a/src/App/Components/Shared/UpdateBanner.razor b/src/App/Components/Shared/UpdateBanner.razor
index 76ba983..b37bee4 100644
--- a/src/App/Components/Shared/UpdateBanner.razor
+++ b/src/App/Components/Shared/UpdateBanner.razor
@@ -1,5 +1,7 @@
@using MeterVault.Infrastructure.Update
@inject UpdateCheckService Updates
+@inject UpdateRunner Runner
+@inject ISnackbar Snackbar
@* Renders nothing at all unless a newer release genuinely exists — no "you are up to date" noise. *@
@if (_status is { UpdateAvailable: true, Running: { } running, Latest: { } latest })
@@ -7,16 +9,50 @@
MeterVault @latest is available — this instance runs @running.
-
+ @if (Runner.Availability is UpdateAvailability.Allowed)
+ {
+
+ Update now
+
+ }
+ else
+ {
@_command
-
+ }
}
+
+ Update MeterVault
+
+
+ This pulls the latest source, rebuilds it, and restarts the service. It takes a few minutes,
+ during which MeterVault is unavailable. Readings are not affected — ingestion resumes on restart.
+
+ @* The UI has no login, so the key is what actually authorises this — not the click. It is
+ typed each time rather than remembered, so a browser left open on this page is not a
+ standing permission to execute code on the host. *@
+
+
+
+ Cancel
+
+ @(_starting ? "Starting…" : "Update now")
+
+
+
+
@code {
private UpdateStatus? _status;
private string _command = "";
+ private bool _confirmOpen;
+ private bool _starting;
+ private string? _apiKey;
+ private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true };
protected override void OnInitialized()
{
@@ -41,13 +77,41 @@
}
}
+ private async Task StartUpdateAsync()
+ {
+ _starting = true;
+ try
+ {
+ if (!Runner.IsAuthorised(_apiKey))
+ {
+ // Same message either way: whether a key is wrong or the deployment has none is not
+ // something an unauthenticated caller should be able to distinguish.
+ Snackbar.Add("That API key was not accepted.", Severity.Error);
+ return;
+ }
+
+ var launch = await Runner.LaunchAsync();
+ Snackbar.Add(launch.Message, launch.Started ? Severity.Success : Severity.Error);
+ if (launch.Started)
+ {
+ _confirmOpen = false;
+ }
+ }
+ finally
+ {
+ // Never keep the key in component state: this circuit outlives the dialog.
+ _apiKey = null;
+ _starting = false;
+ }
+ }
+
///
/// How this particular install updates. The LXC has an update command; a container is
/// replaced by pulling a new image, and telling those users to run update would send them
/// looking for a command that does not exist.
///
private static string UpdateCommandHint() =>
- File.Exists("/usr/bin/update")
+ UpdateRunner.IsSupportedHere
? "run: update"
: "pull the new image and recreate the container";
}
diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs
index 76aae35..145d6a2 100644
--- a/src/Infrastructure/DependencyInjection.cs
+++ b/src/Infrastructure/DependencyInjection.cs
@@ -42,6 +42,7 @@ public static class DependencyInjection
services.AddSingleton();
// Singleton: it caches the last answer so the dashboard never waits on a remote call.
services.AddSingleton();
+ services.AddSingleton();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/Infrastructure/Options/MeterVaultOptions.cs b/src/Infrastructure/Options/MeterVaultOptions.cs
index a9794f1..d8d873d 100644
--- a/src/Infrastructure/Options/MeterVaultOptions.cs
+++ b/src/Infrastructure/Options/MeterVaultOptions.cs
@@ -32,6 +32,16 @@ public sealed class MeterVaultOptions
/// How long full-resolution raw readings are retained (SDD §5.5, default 3 years).
public int RawRetentionDays { get; set; } = 1095;
+ ///
+ /// Allow an update to be triggered from the UI/API. Off by default, and deliberately. The
+ /// updater builds whatever is on the branch and the LXC runs this app as root, so enabling it
+ /// turns a valid API key into arbitrary code execution on the host. It additionally requires at
+ /// least one configured API key: an anonymous-API deployment can never reach it, because opening
+ /// reads must not open root. Only sensible where the UI is behind an authenticating proxy or on
+ /// a network you fully trust.
+ ///
+ public bool AllowInAppUpdate { get; set; }
+
///
/// Compare the running build against the newest published release and show a banner when behind.
/// Set false for an air-gapped instance, or one that should make no outbound requests at all.
diff --git a/src/Infrastructure/Update/UpdateRunner.cs b/src/Infrastructure/Update/UpdateRunner.cs
new file mode 100644
index 0000000..4beb4b4
--- /dev/null
+++ b/src/Infrastructure/Update/UpdateRunner.cs
@@ -0,0 +1,160 @@
+using System.Diagnostics;
+using System.Security.Cryptography;
+using System.Text;
+using MeterVault.Infrastructure.Options;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace MeterVault.Infrastructure.Update;
+
+/// Why an in-app update cannot be started, or if it can.
+public enum UpdateAvailability
+{
+ Allowed,
+
+ /// The operator has not set MeterVault__AllowInAppUpdate.
+ NotEnabled,
+
+ /// No API key is configured, so no request could ever be authorised to do this.
+ NoApiKeyConfigured,
+
+ /// This install has no update mechanism — a container is replaced, not updated in place.
+ NotSupportedHere,
+}
+
+/// Outcome of trying to launch the updater.
+public sealed record UpdateLaunch(bool Started, string Message);
+
+///
+/// Starts the in-container updater on request, gated hard.
+///
+///
+/// This is the most dangerous thing in the codebase, so the reasoning is written down. The updater
+/// runs git reset --hard and dotnet publish against whatever is on the branch, and in
+/// the LXC the app runs as root — so anything able to trigger it can execute arbitrary code as root.
+/// The web UI has no authentication, so "reachable from the dashboard" alone would mean any device
+/// on the network could take the host.
+///
+/// Three independent conditions must therefore hold: the operator opted in explicitly, at least one
+/// API key exists, and the caller presented one. The opt-in is not merely a convenience toggle — with
+/// it off there is no code path to launch at all. Notably an anonymous-API deployment
+/// () can never reach this: that flag opens reads,
+/// and opening reads must not open root.
+///
+public sealed class UpdateRunner(IOptions options, ILogger logger)
+{
+ private const string UpdateCommandPath = "/usr/bin/update";
+ private const string TransientUnit = "metervault-update";
+
+ private readonly MeterVaultOptions _options = options.Value;
+ private readonly ILogger _logger = logger;
+
+ /// True where an in-place update exists at all — the LXC install, not a container.
+ public static bool IsSupportedHere => OperatingSystem.IsLinux() && File.Exists(UpdateCommandPath);
+
+ /// Whether an update could be started, ignoring whether any particular caller may.
+ public UpdateAvailability Availability
+ {
+ get
+ {
+ if (!_options.AllowInAppUpdate)
+ {
+ return UpdateAvailability.NotEnabled;
+ }
+
+ // Without a key nothing can authenticate, and this must never fall back to open access.
+ if (_options.ApiKeys.Count == 0)
+ {
+ return UpdateAvailability.NoApiKeyConfigured;
+ }
+
+ return IsSupportedHere ? UpdateAvailability.Allowed : UpdateAvailability.NotSupportedHere;
+ }
+ }
+
+ ///
+ /// Whether the presented key authorises an update. Compared in constant time: a naive comparison
+ /// leaks key material through response timing to a caller who can retry indefinitely.
+ ///
+ public bool IsAuthorised(string? providedKey)
+ {
+ if (string.IsNullOrEmpty(providedKey) || _options.ApiKeys.Count == 0)
+ {
+ return false;
+ }
+
+ var provided = Encoding.UTF8.GetBytes(providedKey);
+ var matched = false;
+ foreach (var candidate in _options.ApiKeys)
+ {
+ // No early exit: check every key so the time taken does not reveal which one matched.
+ var expected = Encoding.UTF8.GetBytes(candidate);
+ matched |= CryptographicOperations.FixedTimeEquals(provided, expected);
+ }
+
+ return matched;
+ }
+
+ ///
+ /// Launches the updater detached from this process and returns immediately.
+ ///
+ ///
+ /// The updater stops and restarts the service, so a child of this process would be killed
+ /// half-way through — leaving the app down with a partially published build. systemd-run
+ /// puts it in its own transient unit, which survives us dying and is what makes "click, wait,
+ /// come back" possible at all. Callers must have checked and
+ /// first; this re-checks availability rather than trusting them.
+ ///
+ public async Task LaunchAsync(CancellationToken cancellationToken = default)
+ {
+ if (Availability is not UpdateAvailability.Allowed)
+ {
+ return new UpdateLaunch(false, $"Update cannot be started: {Availability}.");
+ }
+
+ try
+ {
+ var start = new ProcessStartInfo("systemd-run")
+ {
+ RedirectStandardError = true,
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ };
+
+ // --collect reaps the unit when it finishes, so a second update is not blocked by the
+ // corpse of the first.
+ start.ArgumentList.Add("--collect");
+ start.ArgumentList.Add($"--unit={TransientUnit}");
+ start.ArgumentList.Add("--description=MeterVault in-app update");
+ start.ArgumentList.Add(UpdateCommandPath);
+
+ using var process = Process.Start(start);
+ if (process is null)
+ {
+ return new UpdateLaunch(false, "Could not start systemd-run.");
+ }
+
+ await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ if (process.ExitCode != 0)
+ {
+ var error = (await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).Trim();
+
+ // Most likely an update already running: the unit name is taken until it is collected.
+ _logger.LogWarning("systemd-run failed ({ExitCode}): {Error}", process.ExitCode, error);
+ return new UpdateLaunch(false,
+ string.IsNullOrWhiteSpace(error) ? "Could not start the update." : error);
+ }
+
+ // Deliberately loud, and the only record that this happened: the update restarts the app,
+ // so nothing written after this survives in memory.
+ _logger.LogWarning("In-app update authorised and started as transient unit {Unit}", TransientUnit);
+ return new UpdateLaunch(true,
+ "Update started. The service restarts when the rebuild finishes — this usually takes a few minutes.");
+ }
+ catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException)
+ {
+ _logger.LogWarning(ex, "Could not launch the updater");
+ return new UpdateLaunch(false, $"Could not launch the updater: {ex.Message}");
+ }
+ }
+}
diff --git a/tests/Integration.Tests/ApiTests.cs b/tests/Integration.Tests/ApiTests.cs
index 48cb13f..7cd384f 100644
--- a/tests/Integration.Tests/ApiTests.cs
+++ b/tests/Integration.Tests/ApiTests.cs
@@ -11,6 +11,28 @@ public sealed class ApiTests(TimescaleFixture fx)
{
private sealed record ReadingPush(int MeterId, DateTimeOffset Time, double Value);
+ [Fact]
+ public async Task Update_endpoint_is_shut_by_default_and_not_openable_anonymously()
+ {
+ // Through the real pipeline, not just the runner: an install that never opted in must return
+ // a refusal for this endpoint even when the caller presents a valid key, and even when the
+ // API itself has been opened anonymously. 409 rather than 401 — the endpoint is disabled,
+ // which is a different fact from the caller being unauthenticated.
+ using var factory = new MeterVaultAppFactory(fx.ConnectionString, configureApiKey: true);
+ using var client = factory.CreateClient();
+
+ using var withKey = new HttpRequestMessage(HttpMethod.Post, "/api/v1/system/update");
+ withKey.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
+ var authorised = await client.SendAsync(withKey);
+ Assert.Equal(HttpStatusCode.Conflict, authorised.StatusCode);
+
+ // And without a key it is certainly not reachable.
+ var anonymous = await client.PostAsync(new Uri("/api/v1/system/update", UriKind.Relative), content: null);
+ Assert.True(
+ anonymous.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Conflict,
+ $"expected the update endpoint to refuse an unauthenticated caller, got {anonymous.StatusCode}.");
+ }
+
[Fact]
public async Task Readings_push_requires_key_and_writes_when_authorized()
{
diff --git a/tests/Integration.Tests/UpdateRunnerTests.cs b/tests/Integration.Tests/UpdateRunnerTests.cs
new file mode 100644
index 0000000..6bce300
--- /dev/null
+++ b/tests/Integration.Tests/UpdateRunnerTests.cs
@@ -0,0 +1,110 @@
+using MeterVault.Infrastructure.Options;
+using MeterVault.Infrastructure.Update;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace MeterVault.Integration.Tests;
+
+///
+/// The gate in front of the in-app update. Triggering it runs a build of whatever is on the branch,
+/// as root on the LXC, so every one of these is a case where getting it wrong hands the host over.
+///
+///
+/// None of these launch anything: they assert the conditions that must hold before a launch
+/// is even attempted, which is the part worth pinning. The launch itself needs systemd and a real
+/// /usr/bin/update.
+///
+public sealed class UpdateRunnerTests
+{
+ private const string Key = "correct-horse-battery-staple";
+
+ [Fact]
+ public void Disabled_by_default()
+ {
+ // The single most important assertion here: an operator who has not thought about this does
+ // not get a remote-code-execution endpoint by upgrading.
+ Assert.False(new MeterVaultOptions().AllowInAppUpdate);
+ Assert.Equal(UpdateAvailability.NotEnabled, NewRunner(new MeterVaultOptions()).Availability);
+ }
+
+ [Fact]
+ public void Refuses_when_enabled_but_no_api_key_is_configured()
+ {
+ // Otherwise "enabled" would mean anyone at all, since there would be no key to present.
+ var runner = NewRunner(new MeterVaultOptions { AllowInAppUpdate = true });
+
+ Assert.Equal(UpdateAvailability.NoApiKeyConfigured, runner.Availability);
+ }
+
+ [Fact]
+ public void An_anonymous_api_deployment_cannot_reach_it()
+ {
+ // AllowAnonymousApi opens reads. It must not open root: with no keys configured this stays
+ // shut regardless of that flag.
+ var runner = NewRunner(new MeterVaultOptions
+ {
+ AllowInAppUpdate = true,
+ AllowAnonymousApi = true,
+ });
+
+ Assert.Equal(UpdateAvailability.NoApiKeyConfigured, runner.Availability);
+ Assert.False(runner.IsAuthorised(null));
+ Assert.False(runner.IsAuthorised(""));
+ Assert.False(runner.IsAuthorised("anything"));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("wrong")]
+ [InlineData("correct-horse-battery-stapl")] // prefix of a valid key
+ [InlineData("correct-horse-battery-staple ")] // trailing space
+ [InlineData("CORRECT-HORSE-BATTERY-STAPLE")] // case differs
+ public void Rejects_anything_that_is_not_exactly_a_configured_key(string? provided)
+ {
+ Assert.False(NewRunner(Enabled()).IsAuthorised(provided));
+ }
+
+ [Fact]
+ public void Accepts_an_exact_key_and_any_of_several()
+ {
+ var options = Enabled();
+ options.ApiKeys.Add("second-key");
+ var runner = NewRunner(options);
+
+ Assert.True(runner.IsAuthorised(Key));
+ Assert.True(runner.IsAuthorised("second-key"));
+ }
+
+ [Fact]
+ public async Task Refuses_to_launch_when_not_allowed_even_if_asked_directly()
+ {
+ // Defence in depth: LaunchAsync re-checks rather than trusting its caller to have done so.
+ var launch = await NewRunner(new MeterVaultOptions()).LaunchAsync();
+
+ Assert.False(launch.Started);
+ Assert.Contains("NotEnabled", launch.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Reports_unsupported_where_there_is_no_in_place_updater()
+ {
+ // On a dev box or in a container there is no /usr/bin/update, so a fully configured runner
+ // still declines rather than half-running something.
+ if (UpdateRunner.IsSupportedHere)
+ {
+ return; // running on a provisioned LXC; the negative case cannot be observed here
+ }
+
+ Assert.Equal(UpdateAvailability.NotSupportedHere, NewRunner(Enabled()).Availability);
+ }
+
+ private static MeterVaultOptions Enabled()
+ {
+ var options = new MeterVaultOptions { AllowInAppUpdate = true };
+ options.ApiKeys.Add(Key);
+ return options;
+ }
+
+ private static UpdateRunner NewRunner(MeterVaultOptions options) =>
+ new(Microsoft.Extensions.Options.Options.Create(options), NullLogger.Instance);
+}