diff --git a/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs b/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs
index 8ada0350..ca8385df 100644
--- a/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs
+++ b/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs
@@ -269,6 +269,47 @@ public async Task Create_sets_up_staged_terminal_slots_in_the_same_save()
}
}
+ ///
+ /// §15.4 — deleting a position takes its terminal slots with it. Without the
+ /// cascade a soft-deleted position left its slots Pending/Active and their
+ /// managed OAuth clients registered, pointing at a principal that no longer
+ /// exists (found by clicking through the running container).
+ ///
+ [Fact]
+ public async Task Deleting_a_position_revokes_its_slots_and_deletes_their_clients()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ SetFeatureFlag(true);
+
+ var resp = await Client.PostAsJsonAsync("/api/position", new
+ {
+ AccountName = "portier.cascade",
+ TerminalPolicy = new { Enabled = true },
+ Terminals = new[] { new { DisplayName = "Terminal links", WebAuthnRpId = "alerthub.example.com" } },
+ }, JsonOptions, ct);
+ Assert.True(resp.IsSuccessStatusCode, $"create failed: {await resp.Content.ReadAsStringAsync(ct)}");
+ var created = (await resp.Content.ReadFromJsonAsync(JsonOptions, ct))!;
+
+ var slot = (await Client.GetFromJsonAsync>(
+ $"/api/position/{created.Id}/terminals", JsonOptions, ct))!.Single();
+
+ var delete = await Client.DeleteAsync($"/api/position/{created.Id}", ct);
+ Assert.Equal(HttpStatusCode.OK, delete.StatusCode);
+
+ using var scope = Factory.Services.CreateScope();
+ var session = scope.ServiceProvider.GetRequiredService();
+
+ var enrollment = await session.LoadAsync(new ShortGuid(slot.Id).Guid, ct);
+ Assert.NotNull(enrollment);
+ Assert.Equal(TerminalEnrollmentStatus.Revoked, enrollment!.Status);
+
+ // Same shape as the per-slot revoke: the client document stays for audit
+ // but is soft-deleted, so nothing can authenticate with it any more.
+ var client = (await session.Query()
+ .Where(c => c.ClientId == slot.ClientId).ToListAsync(ct)).Single();
+ Assert.True(client.IsDeleted);
+ }
+
/// Plan §4.1 holds at create time too: slots need terminal use.
/// The rejection is all-or-nothing — no position, no orphaned client.
[Fact]
diff --git a/src/dotnet/Modgud.Api/Features/Positions/PositionHub.cs b/src/dotnet/Modgud.Api/Features/Positions/PositionHub.cs
new file mode 100644
index 00000000..c425e03f
--- /dev/null
+++ b/src/dotnet/Modgud.Api/Features/Positions/PositionHub.cs
@@ -0,0 +1,42 @@
+using System.Reactive.Linq;
+using BuildingBlocks.EventDispatcher;
+using Cocoar.SignalARRR.Common.Attributes;
+using Cocoar.SignalARRR.Server;
+using Microsoft.AspNetCore.SignalR;
+using Modgud.Api.Realtime;
+
+namespace Modgud.Api.Features.Positions;
+
+///
+/// Per-entity SignalR pipe for the admin Positions grid — the counterpart of
+/// ServiceAccountHub. The endpoint layer pushes Created/Updated/Deleted
+/// through with subject "Position"; this hub
+/// forwards them to the connected admin SPA clients.
+///
+/// Missing since MG-FT-01: the Pinia store has always subscribed with
+/// enableSignalR: true, so every admin session logged
+/// "Method 'PositionActions.Subscribe' not found!" and the grid never updated
+/// live.
+///
+[MessageName("PositionActions")]
+public class PositionHub(DataEventDispatcher eventDispatcher, AppSettings settings)
+ : ServerMethods
+{
+ public IObservable Subscribe()
+ {
+ // Defense in depth like the REST surface: while the feature is dark
+ // there is nothing to stream (and nothing dispatches "Position" either).
+ if (!settings.Features.PositionTerminals) return Observable.Empty();
+
+ // Scope to this connection's realm (resolved at connect by
+ // RealmMiddleware). Untagged events never match → no cross-realm leak.
+ var http = Context.GetHttpContext();
+ var realm = HubAuthorization.CallerRealm(http);
+
+ var source = eventDispatcher.Notifications
+ .Where(ev => ev.Subject == "Position" && ev.Tenant == realm);
+
+ // Per-method permission gate, matching the REST list endpoint.
+ return HubAuthorization.AuthorizedRealmStream(http, realm, "position:read", source);
+ }
+}
diff --git a/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs
index 3b1bef3e..6eb9b0fc 100644
--- a/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs
+++ b/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs
@@ -256,9 +256,12 @@ await staffingRevoker.EndAllForPositionAsync(
ShortGuid id,
AppSettings settings,
IDocumentSession session,
+ OAuthAdminService oauth,
DataEventDispatcher dispatcher,
IOAuthGrantRevoker revoker,
IStaffingRevoker staffingRevoker,
+ Wolverine.IMessageBus bus,
+ HttpContext httpContext,
CancellationToken ct) =>
{
if (!settings.Features.PositionTerminals) return Results.NotFound();
@@ -266,6 +269,24 @@ await staffingRevoker.EndAllForPositionAsync(
var fn = await session.LoadAsync(id.Guid, ct);
if (fn is null || fn.IsDeleted) return Results.NotFound();
+ var deleteActor = PositionGrantsEndpoints.RequireActor(httpContext);
+ var deletedAt = DateTimeOffset.UtcNow;
+
+ // §15.4 — the slots go with the position. Without this a deleted
+ // position left its terminal slots Pending/Active and their
+ // managed OAuth clients registered: orphans pointing at a
+ // principal that no longer exists. Same steps the per-slot
+ // revoke takes, staged into this delete's unit of work.
+ var slots = await session.Query()
+ .Where(t => t.PositionPrincipalId == id.Guid && t.Status != TerminalEnrollmentStatus.Revoked)
+ .ToListAsync(ct);
+ foreach (var slot in slots)
+ {
+ session.Events.Append(slot.Id, new TerminalEnrollmentRevoked(slot.Id, deleteActor, deletedAt));
+ if (await oauth.StageDeleteTerminalClientAsync(slot.OAuthApplicationId, ct) is { } slotError)
+ return Results.BadRequest(new { Error = slotError.Code, Message = slotError.Description });
+ }
+
// Soft delete via the stream: the projection flips IsDeleted (and
// IsActive) so audit / group-membership references stay resolvable.
session.Events.Append(id.Guid, new PositionPrincipalDeletedEvent(id.Guid));
@@ -280,6 +301,16 @@ await staffingRevoker.EndAllForPositionAsync(
await revoker.RevokeTokensBySubjectAsync(subject, ct);
await revoker.RevokeAuthorizationsBySubjectAsync(subject, ct);
+ // Each revoked slot's device is cut off now, not at token expiry,
+ // and consumers hear about it (MG-FT-09 §17).
+ foreach (var slot in slots)
+ {
+ await revoker.RevokeTokensByApplicationIdAsync(slot.OAuthApplicationId.ToString(), ct);
+ dispatcher.DispatchDeletedEvent("Terminal", new ShortGuid(slot.Id).ToString(), session.TenantId);
+ await bus.PublishAsync(new Modgud.Domain.PositionTerminals.Contracts.V1.PositionTerminalStatusChanged(
+ fn.Id, slot.Id, TerminalEnrollmentStatus.Revoked, deletedAt));
+ }
+
dispatcher.DispatchDeletedEvent("Position", new ShortGuid(fn.Id).ToString(), session.TenantId);
return Results.Ok();
})
diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json
index eeb3ae53..9178ec8f 100644
--- a/src/frontend-vue/public/i18n/de.json
+++ b/src/frontend-vue/public/i18n/de.json
@@ -12,6 +12,7 @@
"ok": "OK",
"apply": "Übernehmen",
"delete": "Löschen",
+ "remove": "Entfernen",
"open": "Öffnen",
"clone": "Klonen",
"close": "Schließen",