feat(connections): edit connections via PUT — backend + UI + blank-secret merge

Backend: PUT /api/connections/{id} with UpdateConnectionRequest {Name, Config}.
Provider is immutable; a secret field left blank in the incoming config is
merged from the stored row so users can rename or rotate non-secret fields
without re-typing every credential. Invalidates the ConnectionInstanceRegistry
cache on update so the next GetConnection() re-opens with the fresh config.
409 on unique-name-per-provider collision, 404 on unknown id, 400 on non-
object config. 6 new xUnit tests cover the merge, overwrite, 404, 409, 400
paths plus the happy-path round-trip.

Frontend: ConnectionsPage gains an Edit pencil per row. The form is shared
between create and edit modes; provider select is disabled in edit; secret
fields render blank with an inline hint that leaving them blank keeps the
stored value. One new Vitest covers the edit flow.
This commit is contained in:
Claude
2026-08-28 19:50:08 +00:00
parent d12581ad12
commit 4b30d4a44e
7 changed files with 428 additions and 20 deletions

View File

@@ -3,4 +3,5 @@ using System.Text.Json;
namespace VolcanicCore.Api.Contracts;
public sealed record CreateConnectionRequest(string ProviderId, string Name, JsonElement Config);
public sealed record UpdateConnectionRequest(string Name, JsonElement Config);
public sealed record ConnectionResponse(string Id, string ProviderId, string Name, JsonElement Config);

View File

@@ -47,6 +47,36 @@ public static class ConnectionsEndpoints
}
});
g.MapPut("/{id}", async (string id, UpdateConnectionRequest req,
ConnectionRepository repo, IConnectionSecretPolicy policy,
ConnectionInstanceRegistry instances) =>
{
if (req.Config.ValueKind != JsonValueKind.Object)
return Results.BadRequest(new { error = "config must be a JSON object" });
var existing = repo.Get(id);
if (existing is null) return Results.NotFound();
var secrets = policy.SecretFieldsFor(existing.ProviderId);
var merged = MergeSecrets(existing.ConfigJson, req.Config, secrets);
try
{
if (!repo.Update(id, req.Name, merged)) return Results.NotFound();
}
catch (Microsoft.Data.Sqlite.SqliteException ex) when (ex.SqliteErrorCode == 19)
{
return Results.Conflict(new { error = "a connection with that name already exists for this provider" });
}
// Config changed — drop the pooled live instance so the next
// GetConnection() re-opens with the updated config.
await instances.InvalidateAsync(id);
var stripped = StripSecrets(merged, secrets);
return Results.Ok(new ConnectionResponse(id, existing.ProviderId, req.Name, stripped));
});
g.MapDelete("/{id}", async (string id, ConnectionRepository repo, ConnectionInstanceRegistry instances) =>
{
await instances.InvalidateAsync(id);
@@ -54,6 +84,26 @@ public static class ConnectionsEndpoints
});
}
// For each field in `secrets`, if the incoming config lacks that key OR
// the value is an empty string, use the stored value from `existingJson`.
// Non-secret fields always come from the incoming config. This gives
// "leave blank to keep the existing secret" without exposing it on GET.
private static string MergeSecrets(string existingJson, JsonElement incoming, IReadOnlySet<string> secrets)
{
using var existingDoc = JsonDocument.Parse(existingJson);
var merged = new Dictionary<string, JsonElement>();
foreach (var p in incoming.EnumerateObject()) merged[p.Name] = p.Value.Clone();
foreach (var s in secrets)
{
var incomingBlank =
!merged.TryGetValue(s, out var v)
|| (v.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(v.GetString()));
if (incomingBlank && existingDoc.RootElement.TryGetProperty(s, out var kept))
merged[s] = kept.Clone();
}
return JsonSerializer.Serialize(merged);
}
private static JsonElement StripSecrets(string configJson, IReadOnlySet<string> secretFields)
{
using var doc = JsonDocument.Parse(configJson);

View File

@@ -63,6 +63,22 @@ public sealed class ConnectionRepository
return rows;
}
public bool Update(string id, string name, string configJson)
{
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
UPDATE connections
SET name = $name,
config_json_encrypted = $enc
WHERE id = $id;
";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$name", name);
cmd.Parameters.AddWithValue("$enc", _enc.Encrypt(configJson));
return cmd.ExecuteNonQuery() == 1;
}
public bool Delete(string id)
{
using var conn = _factory.Open();