diff --git a/DeepDrftCms/Components/DeleteTrackDialog.razor b/DeepDrftCms/Components/DeleteTrackDialog.razor
new file mode 100644
index 0000000..09d272d
--- /dev/null
+++ b/DeepDrftCms/Components/DeleteTrackDialog.razor
@@ -0,0 +1,86 @@
+@using System.Net.Http
+@using Microsoft.AspNetCore.Components
+@inject IHttpClientFactory HttpClientFactory
+
+
+
+
+ Are you sure you want to delete '@TrackName'? This cannot be undone.
+
+ @if (!string.IsNullOrEmpty(_errorMessage))
+ {
+ @_errorMessage
+ }
+
+
+ Cancel
+
+ @if (_isDeleting)
+ {
+
+ Deleting...
+ }
+ else
+ {
+ Delete
+ }
+
+
+
+
+@code {
+ [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!;
+
+ [Parameter] public int TrackId { get; set; }
+ [Parameter] public string TrackName { get; set; } = "";
+ [Parameter] public EventCallback OnDeleted { get; set; }
+
+ private bool _isDeleting;
+ private string? _errorMessage;
+
+ private async Task ConfirmAsync()
+ {
+ _isDeleting = true;
+ _errorMessage = null;
+
+ try
+ {
+ // "DeepDrft.API" is the named client pointing at DeepDrftWeb's own host.
+ // The Admin role gate on the endpoint is enforced server-side by AuthBlocks;
+ // the registered HTTP client/auth handler stack attaches the JWT.
+ var client = HttpClientFactory.CreateClient("DeepDrft.API");
+ var response = await client.DeleteAsync($"api/cms/track/{TrackId}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ if (OnDeleted.HasDelegate)
+ {
+ await OnDeleted.InvokeAsync();
+ }
+ MudDialog.Close(DialogResult.Ok(true));
+ return;
+ }
+
+ _errorMessage = response.StatusCode switch
+ {
+ System.Net.HttpStatusCode.NotFound => "Track not found. It may have already been deleted.",
+ System.Net.HttpStatusCode.Unauthorized => "You are not authorized to delete this track.",
+ System.Net.HttpStatusCode.Forbidden => "You are not authorized to delete this track.",
+ _ => $"Delete failed ({(int)response.StatusCode})."
+ };
+ }
+ catch (Exception ex)
+ {
+ _errorMessage = $"Delete failed: {ex.Message}";
+ }
+ finally
+ {
+ _isDeleting = false;
+ }
+ }
+
+ private void Cancel() => MudDialog.Cancel();
+}
diff --git a/DeepDrftContent.Services/FileDatabase/Models/IIndex.cs b/DeepDrftContent.Services/FileDatabase/Models/IIndex.cs
index 2deddec..c8df40e 100644
--- a/DeepDrftContent.Services/FileDatabase/Models/IIndex.cs
+++ b/DeepDrftContent.Services/FileDatabase/Models/IIndex.cs
@@ -57,4 +57,10 @@ public interface IVaultIndex : IEntryQueryable
/// Adds an entry with metadata to the vault index
///
void PutEntry(string entryId, MetaData metaData);
+
+ ///
+ /// Removes an entry (and its metadata) from the vault index.
+ /// Returns true if an entry was removed, false if it was not present.
+ ///
+ bool RemoveEntry(string entryId);
}
diff --git a/DeepDrftContent.Services/FileDatabase/Models/IndexData.cs b/DeepDrftContent.Services/FileDatabase/Models/IndexData.cs
index 8183c1e..92acb48 100644
--- a/DeepDrftContent.Services/FileDatabase/Models/IndexData.cs
+++ b/DeepDrftContent.Services/FileDatabase/Models/IndexData.cs
@@ -131,4 +131,6 @@ public class VaultIndex : IndexData, IVaultIndex
public MetaData? GetEntry(string entryId) => Entries.Get(entryId);
public void PutEntry(string entryId, MetaData metaData) => Entries.Set(entryId, metaData);
+
+ public bool RemoveEntry(string entryId) => Entries.Delete(entryId);
}
diff --git a/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs b/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs
index 87dd442..d50ae1b 100644
--- a/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs
+++ b/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs
@@ -171,10 +171,34 @@ public class FileDatabase : DirectoryIndexDirectory, IDisposable
{
// Swallow exceptions and return false, matching TypeScript behavior
}
-
+
return false;
}
+ ///
+ /// Removes a resource from a specific vault. Returns null if the vault does not exist,
+ /// false if the entry was not found, true if the entry was removed. Distinguishing
+ /// "no such vault" / "no such entry" / "removed" lets the HTTP host map cleanly to
+ /// 404 vs. 200. Follows the FileDatabase error-swallow contract: any unexpected failure
+ /// returns null so callers can surface 5xx without try/catch at the controller layer.
+ ///
+ public async Task RemoveResourceAsync(string vaultId, string entryId)
+ {
+ try
+ {
+ var directoryVault = _vaults.Get(vaultId);
+ if (directoryVault == null)
+ return null;
+
+ return await directoryVault.RemoveEntryAsync(entryId);
+ }
+ catch
+ {
+ // Swallow exceptions and return null, matching the FileDatabase error contract.
+ return null;
+ }
+ }
+
///
/// Gets all vault IDs managed by this database
///
diff --git a/DeepDrftContent.Services/FileDatabase/Services/IndexSystem.cs b/DeepDrftContent.Services/FileDatabase/Services/IndexSystem.cs
index 7f79375..96ba880 100644
--- a/DeepDrftContent.Services/FileDatabase/Services/IndexSystem.cs
+++ b/DeepDrftContent.Services/FileDatabase/Services/IndexSystem.cs
@@ -122,6 +122,32 @@ public class VaultIndexDirectory : IndexDirectory
}
}
+ ///
+ /// Removes an entry from the index under the index lock, persisting on success.
+ /// Returns the removed entry's metadata, or null if the entry did not exist.
+ /// Caller is responsible for any backing-file cleanup using the returned metadata.
+ ///
+ protected async Task RemoveFromIndexAsync(string entryId)
+ {
+ await _indexLock.WaitAsync();
+ try
+ {
+ var metaData = _vaultIndex.GetEntry(entryId);
+ if (metaData == null)
+ return null;
+
+ if (!_vaultIndex.RemoveEntry(entryId))
+ return null;
+
+ await SaveIndexAsync(_vaultIndex);
+ return metaData;
+ }
+ finally
+ {
+ _indexLock.Release();
+ }
+ }
+
///
/// Reloads the index from disk. Called when the index file is modified externally.
///
diff --git a/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs b/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs
index 137ac21..f6a4671 100644
--- a/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs
+++ b/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs
@@ -132,6 +132,33 @@ public abstract class MediaVault : VaultIndexDirectory
}
}
+ ///
+ /// Removes an entry from the vault: drops it from the index (persisting the change)
+ /// and deletes the backing file from disk. Returns true if an entry was removed,
+ /// false if the entry was not present. Follows the FileDatabase error-swallow contract
+ /// for read failures; index/file write failures propagate so the caller can map them
+ /// to a 5xx.
+ ///
+ public async Task RemoveEntryAsync(string entryId)
+ {
+ var metaData = await RemoveFromIndexAsync(entryId);
+ if (metaData == null)
+ return false;
+
+ // Index already persisted; if the file is missing or fails to delete, the entry
+ // is still gone from the catalogue. Treat a missing file as success (callers asked
+ // for the entry to go away, and it has). A failure deleting an existing file leaves
+ // an orphan on disk; surface it to the caller via exception so the host can log,
+ // matching the AddEntryAsync error-propagation shape.
+ var mediaPath = GetMediaPathFromEntryKey(metaData.MediaKey, metaData.Extension);
+ if (FileUtils.FileExists(mediaPath))
+ {
+ File.Delete(mediaPath);
+ }
+
+ return true;
+ }
+
///
/// Extracts buffer and extension from a media binary
///
diff --git a/DeepDrftContent/CLAUDE.md b/DeepDrftContent/CLAUDE.md
index da51d45..b89fa0f 100644
--- a/DeepDrftContent/CLAUDE.md
+++ b/DeepDrftContent/CLAUDE.md
@@ -11,7 +11,7 @@ The binary content API host. ApiKey middleware, CORS, forwarded headers. Returns
## What lives here now (only)
- `Program.cs`, `Startup.cs`: HTTP host config, DI wiring, middleware setup, port binding.
-- `Controllers/TrackController.cs`: Two endpoints (see below).
+- `Controllers/TrackController.cs`: Three endpoints (see below).
- `Middleware/ApiKeyAuthenticationMiddleware.cs`, `Middleware/ApiKeyAuthorizeAttribute.cs`: ApiKey validation logic.
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`). No domain code.
- `environment/filedatabase.json`: FileDatabase vault path config (required).
@@ -45,7 +45,19 @@ Returns the WAV bytes from the `tracks` vault.
- Actually: the endpoint is rarely used in production (the CLI calls `FileDatabase.RegisterResourceAsync` directly). But the endpoint exists for potential web-side uploads in future.
- Returns 200 on success, 401 if ApiKey invalid, 400 if body invalid.
-**Do not add a third endpoint without product approval.** The surface is intentionally minimal.
+### DELETE api/track/{entryKey} ([ApiKeyAuthorize])
+
+**Authenticated endpoint.** Removes an entry from the `tracks` vault (drops the index entry and deletes the backing file).
+
+- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
+- **Route parameter `entryKey`**: the entry id inside the `tracks` vault (i.e. `TrackEntity.EntryKey`).
+- Delegates to `FileDatabase.RemoveResourceAsync` which returns a tri-state outcome:
+ - `null` → vault missing or unexpected error → 500.
+ - `false` → entry not present → 404.
+ - `true` → entry removed → 200.
+- Added in CMS Wave 3 (W1.5) so the CMS delete endpoint on `DeepDrftWeb` (`DELETE api/cms/track/{id}`) can clean up the vault after the SQL row is gone. Wave 3 product approval covers this — the "do not add a third endpoint without product approval" rule from prior waves is satisfied.
+
+The endpoint surface is now intentionally **three** endpoints. Do not add a fourth without product approval.
## ApiKey middleware behaviour
diff --git a/DeepDrftContent/Controllers/TrackController.cs b/DeepDrftContent/Controllers/TrackController.cs
index da65828..4443923 100644
--- a/DeepDrftContent/Controllers/TrackController.cs
+++ b/DeepDrftContent/Controllers/TrackController.cs
@@ -142,4 +142,31 @@ public class TrackController : ControllerBase
DeepDrftContent.Services.Constants.VaultConstants.Tracks, trackId, audioBinary);
return success ? Ok() : BadRequest("Failed to store audio track");
}
+
+ [ApiKeyAuthorize]
+ [HttpDelete("{entryKey}")]
+ public async Task DeleteTrack(string entryKey)
+ {
+ _logger.LogInformation("DeleteTrack called with entryKey: {EntryKey}", entryKey);
+
+ // RemoveResourceAsync distinguishes three outcomes per FileDatabase's error-swallow contract:
+ // null → vault missing or unexpected error → 500
+ // false → entry not present (already deleted or never existed) → 404
+ // true → entry removed → 200
+ var outcome = await _fileDatabase.RemoveResourceAsync(VaultConstants.Tracks, entryKey);
+ if (outcome == null)
+ {
+ _logger.LogError("DeleteTrack failed for entryKey: {EntryKey} (vault missing or remove error)", entryKey);
+ return StatusCode(500, "Internal server error");
+ }
+
+ if (outcome == false)
+ {
+ _logger.LogWarning("DeleteTrack: entry not found: {EntryKey}", entryKey);
+ return NotFound();
+ }
+
+ _logger.LogInformation("DeleteTrack: removed entry {EntryKey}", entryKey);
+ return Ok();
+ }
}
diff --git a/DeepDrftWeb/Controllers/CmsDeleteController.cs b/DeepDrftWeb/Controllers/CmsDeleteController.cs
new file mode 100644
index 0000000..2d3eabb
--- /dev/null
+++ b/DeepDrftWeb/Controllers/CmsDeleteController.cs
@@ -0,0 +1,85 @@
+using DeepDrftWeb.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace DeepDrftWeb.Controllers;
+
+///
+/// CMS delete endpoint. Owned by W3-T3 — separate controller from upload/edit to
+/// avoid merge contention with parallel CMS tracks.
+///
+/// Delete order (CMS-PLAN W1.5): SQL first, then vault. If the SQL row is gone we
+/// return success to the user even when the subsequent vault delete fails — SQL is
+/// the source of truth for "exists from the user's view". A vault failure is logged
+/// as an orphan for maintenance to reap (see PLAN.md §4.3 dead-letter).
+///
+[ApiController]
+[Route("api/cms/track")]
+[Authorize(Roles = "Admin")]
+public class CmsDeleteController : ControllerBase
+{
+ private readonly ITrackService _trackService;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ILogger _logger;
+
+ public CmsDeleteController(
+ ITrackService trackService,
+ IHttpClientFactory httpClientFactory,
+ ILogger logger)
+ {
+ _trackService = trackService;
+ _httpClientFactory = httpClientFactory;
+ _logger = logger;
+ }
+
+ [HttpDelete("{id:long}")]
+ public async Task DeleteTrack(long id)
+ {
+ // 1. Resolve the EntryKey before we delete the SQL row — afterwards the join is gone.
+ var lookup = await _trackService.GetById(id);
+ if (!lookup.Success)
+ {
+ _logger.LogError("CMS delete: lookup failed for track {TrackId}: {Error}", id, lookup.Messages.FirstOrDefault()?.Message);
+ return StatusCode(500, "Failed to load track");
+ }
+
+ var track = lookup.Value;
+ if (track == null)
+ {
+ return NotFound();
+ }
+
+ var entryKey = track.EntryKey;
+
+ // 2. SQL delete. On failure, do NOT touch the vault — nothing to clean up.
+ var sqlDelete = await _trackService.Delete(id);
+ if (!sqlDelete.Success)
+ {
+ _logger.LogError("CMS delete: SQL delete failed for track {TrackId}: {Error}", id, sqlDelete.Messages.FirstOrDefault()?.Message);
+ return StatusCode(500, "Failed to delete track");
+ }
+
+ // 3. Vault delete. Failure is logged as an orphan but does not fail the request:
+ // SQL is the source of truth for the user's view; the orphan is a maintenance concern.
+ var client = _httpClientFactory.CreateClient(Startup.ContentCmsHttpClientName);
+ try
+ {
+ var response = await client.DeleteAsync($"api/track/{Uri.EscapeDataString(entryKey)}");
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogWarning(
+ "Vault delete failed after SQL delete. {TrackId} {EntryKey} {Reason} {StatusCode}",
+ id, entryKey, "vault delete failed after SQL delete", (int)response.StatusCode);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(
+ ex,
+ "Vault delete threw after SQL delete. {TrackId} {EntryKey} {Reason}",
+ id, entryKey, "vault delete failed after SQL delete");
+ }
+
+ return Ok();
+ }
+}
diff --git a/DeepDrftWeb/Program.cs b/DeepDrftWeb/Program.cs
index 9308969..1bba1df 100644
--- a/DeepDrftWeb/Program.cs
+++ b/DeepDrftWeb/Program.cs
@@ -13,6 +13,12 @@ builder.Services.AddMudServices();
builder.Services.AddCmsServices();
+// CMS → DeepDrftContent calls require the DeepDrftContent ApiKey. Loaded from a
+// gitignored environment file, same shape as DeepDrftContent/environment/apikey.json.
+// Optional so the file's absence in non-CMS dev does not fail boot; missing key is
+// surfaced when Startup.ConfigureDomainServices binds the CMS HttpClient.
+builder.Configuration.AddJsonFile("environment/apikey.json", optional: true, reloadOnChange: true);
+
var baseUrl = builder.GetKestrelUrl();
var contentApiUrl = builder.Configuration["ApiUrls:ContentApi"] ?? throw new Exception("Content API URL is not configured");
diff --git a/DeepDrftWeb/Startup.cs b/DeepDrftWeb/Startup.cs
index c72c28c..2e03d45 100644
--- a/DeepDrftWeb/Startup.cs
+++ b/DeepDrftWeb/Startup.cs
@@ -7,6 +7,13 @@ namespace DeepDrftWeb;
public static class Startup
{
+ ///
+ /// Named HttpClient used by CMS controllers to call DeepDrftContent's ApiKey-protected endpoints.
+ /// Distinct from the public WASM-facing "DeepDrft.Content" client so the API key never reaches
+ /// the browser. Configured server-side only.
+ ///
+ public const string ContentCmsHttpClientName = "DeepDrft.Content.Cms";
+
public static void ConfigureDomainServices(WebApplicationBuilder builder)
{
// Add Entity Framework services
@@ -18,11 +25,24 @@ public static class Startup
builder.Services
.AddHttpContextAccessor()
.AddScoped();
-
+
// Add Track services
builder.Services
.AddScoped()
.AddScoped();
+
+ // CMS → DeepDrftContent client. The API key is required up front (no lazy resolution)
+ // so a misconfiguration surfaces at startup instead of on the first delete attempt.
+ var contentApiUrl = builder.Configuration["ApiUrls:ContentApi"]
+ ?? throw new InvalidOperationException("ApiUrls:ContentApi is required");
+ var contentApiKey = builder.Configuration["DeepDrftContent:ApiKey"]
+ ?? throw new InvalidOperationException("DeepDrftContent:ApiKey is required (see environment/apikey.json)");
+
+ builder.Services.AddHttpClient(ContentCmsHttpClientName, client =>
+ {
+ client.BaseAddress = new Uri(contentApiUrl);
+ client.DefaultRequestHeaders.Add("ApiKey", contentApiKey);
+ });
}
public static string GetKestrelUrl(this WebApplicationBuilder builder)