`, §11.2) — die vorhandenen Shells laufen als
+ je eine Surface. Contract-Tests: Sandbox verweigert Reflection/Loop-DoS; SPA-Root rendert erwartetes HTML;
+ unbekannte Surface → 404.
+- **E2 — Bundle-aware `ITemplateLoader`:** lädt Template-Dateien der Bundles entlang der Prioritätskette
+ (Namespace-Auflösung, Datei-Override).
+- **E3 — Kompositions-Compiler (Baustein B, der harte Teil):** `extends`/`block`/`parent()`, mehrstufig,
+ Datei- + Block-Override, deterministische Reihenfolge (ADR-014 §9.6), Zyklen-Erkennung.
+- **E4 — Render-Cache + deterministische Invalidierung** (ADR-015 §7).
+- **E5 — SurfaceShell-Basis-Views + Referenz-Template-Bundle** (ein Document-Bundle als lebendes Beispiel).
+- **E6 — Contract-Tests** (Datei-/Block-Override, `parent()`, Prioritätsreihenfolge, Zyklen-Fehler).
+- **E7 — Umbenennung `Callora.Workspace → Callora.Surface`** (mechanisch, zuletzt).
+
+## 4. Nicht-Ziele (Phase I)
+
+Page-Builder (ADR-014 §10.4), Asset-Pipeline SCSS→CSS/JS-Bundling (eigener Entscheidungspunkt, ADR-015 §11),
+verteilter Cache, Prerendering für SPA-Islands. Fachlogik bleibt in Feature-Plugins, nicht im Template.
+
+## 5. Sicherheits-Fokus (E1 kritisch)
+
+Die Sandbox ist der sicherheitsrelevante Kern. E1 liefert bereits: Allowlist-`ScriptObject`, `MemberFilter`
+(kein Typ-/Reflection-Durchgriff), Loop-/Recursive-Limit, Output-Cap, Loader-Tiefenlimit. Jeder dieser Guards
+bekommt einen Negativ-Test (Angriff schlägt fehl), nicht nur den Happy-Path.
diff --git a/ops/specs/2026-07-19-surface-unterbau-stufe1-design.md b/ops/specs/2026-07-19-surface-unterbau-stufe1-design.md
new file mode 100644
index 00000000..bce28c55
--- /dev/null
+++ b/ops/specs/2026-07-19-surface-unterbau-stufe1-design.md
@@ -0,0 +1,124 @@
+# Surface-Unterbau (Stufe 1) — Implementierungs-Spec
+
+Stand: 19. Juli 2026
+Status: Bauplan. Design-Autorität = **ADR-014 (Surface-Engine)** §5/§7/§14/§15/§16. Dieses Spec ist
+die konkrete Stufe-1-Ausführung; es erfindet keine Semantik, sondern schneidet die ADR auf Stufe 1 zu.
+Verwandt: [[callora-tenant-workspace-surface-semantik]], ADR-015 (Surface-Template-Engine, Rendering —
+bewusst SPÄTER).
+
+## 1. Ziel & Zuschnitt
+
+Eine **Surface ≈ Shopware-SalesChannel** (ADR-014 §18.1): konkrete Zugangs-/Ausgabefläche *innerhalb*
+eines Workspaces; ein Workspace hat **N Surfaces auf geteilten Daten**. Heute ist das kollabiert — der
+Workspace selbst trägt `PublicHost`/`PublicPathPrefix`/`PublicBaseUrl`/`Theme*` (1 Workspace = 1 Zugang).
+Der Unterbau entfaltet das zu 1→N.
+
+**Stufe-1-Schnitt (nach ADR §15 „Framework-Kern zuerst, Template-Compiler zuletzt"):**
+
+| Phase (ADR) | Inhalt | Stufe 1 |
+|---|---|---|
+| A Surface-Domänenmodell | `WorkspaceSurface`-Entity, AccessMode, Domain-Auflösung, Surface-Scope | **JA** |
+| C Surface-Administration | CRUD-API + Admin-UI | **JA** |
+| D Surface-Runtime | `Host/Pfad → Surface → Workspace`, Access-Policy | **JA** |
+| F/G/H/I SurfaceShell + Template-Bundles + Compiler | Multi-Inheritance-Rendering (Scriban, ADR-015) | **NEIN** — §15: SPA-Root genügt; vorhandene Shells laufen als je 1 Surface |
+| E Identity/Principal-Profile | Employee/Agent/Customer, Audience/Realm-Tiefe | **NEIN** — bis Multi-Audience real gebraucht |
+
+**Nicht-Ziele (Stufe 1):** Template-Compiler/SurfaceShell/Bundle-Mechanismus; volle Audience/
+Auth-Realm-Maschinerie + Identity-Profile; Umbenennung `Callora.Workspace` → `Callora.Surface` (ADR §14,
+später); Preview/Test-Modus für Surfaces.
+
+## 2. Domänenmodell (Phase-A-Kern)
+
+Neue Entity `WorkspaceSurface` (`src/Core/Domain/Workspaces/WorkspaceSurface.cs`), Felder aus ADR §5.2 —
+Audience/Realm bewusst schmal:
+
+| Feld | Typ | Herkunft/Zweck |
+|---|---|---|
+| `Id` | Guid | PK |
+| `WorkspaceId` | Guid (FK) | Workspace-Zugehörigkeit |
+| `SurfaceKey` | string | technischer Schlüssel, **unique je Workspace** |
+| `DisplayName` | string | UI-Name |
+| `SurfaceType` | string | erweiterbarer Schlüssel (ADR §16), Default `"spa"` — KEIN geschlossenes Enum |
+| `PublicHost` | string? | Domain-Auflösung |
+| `PublicPathPrefix` | string (`"/"`) | Entry-Route |
+| `PublicBaseUrl` | string? | Parität zum Workspace heute |
+| `AccessMode` | enum `Public`/`Authenticated`/`Mixed` (ADR §6.1) | Zugriffspolitik |
+| `Locale` | string? | Sprache |
+| `TemplatePluginId`/`TemplateVersion` | string? | Template-Zuweisung (SPA-Root-Default) |
+| `ThemePluginId`/`ThemeVersion`/`ThemeAssignedBy`/`ThemeAssignedAtUtc` | string?/DateTimeOffset? | Theme-Zuweisung (aus Workspace übernommen) |
+| `IsActive` | bool | Lifecycle |
+| `CreatedAtUtc`/`UpdatedAtUtc` | DateTimeOffset | Audit |
+
+EF-Config `WorkspaceSurfaceEntityTypeConfiguration` → Tabelle `workspace_surfaces`; FK auf `workspaces`
+(`OnDelete: Cascade` — Surfaces sterben mit dem Workspace); Unique-Index `(WorkspaceId, SurfaceKey)`;
+Index auf `PublicHost` für die Auflösung. `AccessMode` als string/enum-Konversion.
+`Workspace` bekommt `ICollection
Surfaces`.
+
+**Surface-Scope:** `BackendClaimTypes`/Scope-Mechanik um eine (optionale) Surface-Ebene ergänzen
+(Platform→Tenant→Workspace→Surface, ADR §7) — für Stufe 1 nur die Achse anlegen, noch nicht flächig
+erzwingen.
+
+## 3. Migrationsstrategie (phasiert, non-breaking)
+
+Kern-Entscheidung: **Surfaces additiv einführen, Autoritäts-Umschaltung erst mit der Runtime.** So bleibt
+jeder Baustein reviewbar und bricht nichts.
+
+- **S1 additiv:** `workspace_surfaces`-Tabelle + Entity + Store; EF-Migration `AddWorkspaceSurfaces`
+ **backfillt** je Workspace eine `"default"`-Surface (kopiert `Public*`/`Theme*`, `SurfaceType="spa"`,
+ `AccessMode=Mixed`, `IsActive=workspace.IsActive`) via `migrationBuilder.Sql(...)`. Der Workspace behält
+ seine `Public*`/`Theme*`-Spalten unverändert; Runtime/Snapshot/Shell laufen wie bisher. **Nichts ändert
+ Verhalten.**
+- **S2 Autorität:** Runtime-Auflösung + Theme lesen aus Surfaces (Default-Surface bewahrt heutiges
+ Verhalten). `WorkspacePublicRouteMatcher`/`ResolveByPublicRouteAsync`/Shell-Bootstrap → surface-basiert;
+ Workspace-Upsert schreibt die Default-Surface durch (kein Drift).
+- **S4 Cleanup:** `Workspace.Public*`/`Theme*`-Spalten entfernen (Consumer lesen dann aus Surfaces).
+
+## 4. Bausteine
+
+**S1 — Surface-Domänenmodell + Migration (additiv).**
+- `WorkspaceSurface` Entity + `WorkspaceSurfaceEntityTypeConfiguration` + `Workspace.Surfaces`-Nav.
+- `SurfaceAccessMode` Enum. `WorkspaceSurfaceSnapshot` (Read-Model).
+- `IWorkspaceSurfaceStore` (List/Get/Upsert/Delete je Workspace) + `EfWorkspaceSurfaceStore`.
+- EF-Migration `AddWorkspaceSurfaces` + Backfill-SQL (default-Surface je Workspace).
+- Tests: Store-CRUD (Testcontainers, `[Trait Category=Slow]`), Snapshot-Mapping, Backfill-Unit falls
+ isolierbar. PublicAPI-Baseline.
+- **Rein additiv — keine bestehenden Consumer angefasst.**
+
+**S2 — Surface-Runtime-Auflösung.**
+- `WorkspacePublicRouteMatcher` → `SurfacePublicRouteMatcher` (Host/Pfad → Surface, Score wie heute);
+ `ResolveByPublicRouteAsync` liefert Surface (+ zugehörigen Workspace).
+- Shell-Bootstrap-Payload um `surface.key`/`surface.themePluginId` erweitern
+ (`WorkspacePublicEndpoints`).
+- Workspace-Upsert schreibt Default-Surface durch (Anti-Drift), bis S4 die Spalten entfernt.
+- Tests: Auflösungs-Präzedenz, Default-Surface-Fallback, Bootstrap-Payload.
+
+**S3 — Surface-Admin-API.**
+- `SurfaceApiResponse`/`UpsertSurfaceApiRequest`; `SurfaceEndpoints` unter
+ `/api/workspaces/{workspaceKey}/surfaces` (List/Upsert/Delete), permission-gated
+ (`workspace.surfaces.manage`, ADR §3.5) + Workspace-Scope.
+- Surface-Business-Events (`surface.created/updated/deleted`) analog EV1 (optional, konsistent).
+- Tests: Endpoint-Integration (In-Memory-Store), Permission-Gating.
+
+**S4 — Admin-Frontend `surfaces` + Lücken + Spalten-Cleanup.**
+- Neues Admin-Modul `surfaces/` (List/Detail je Workspace) + Route + Nav-Link in `WorkspaceDetailView`.
+- Übrige Frontend-Lücken (aus Bestandsaufnahme): **Entitlement-Verwaltung-UI**, **permission-gefilterte
+ Navigation** (ADR §3.4/§16 Phase B), echtes **Dashboard**.
+- Cleanup: `Workspace.Public*`/`Theme*`-Spalten entfernen (Migration), Consumer final auf Surfaces.
+- Tests: Vitest (Views/API), .NET-Migration.
+
+## 5. Entscheidungs-Log
+
+| Entscheidung | Begründung |
+|---|---|
+| Stufe-1 = ADR-Phasen A+C+D, Rendering (F–I) + Identity-Profile (E) vertagt | ADR §15 eigene Reihenfolge; SPA-Root genügt, Template-Compiler erst bei 2. Oberflächentyp |
+| Migration additiv (S1), Autorität erst mit Runtime (S2), Spalten-Drop erst S4 | jeder Baustein non-breaking + reviewbar; kein Big-Bang |
+| `SurfaceType` als erweiterbarer String, nicht Enum | ADR §16 („nicht geschlossenes Enum") |
+| Audience/Auth-Realm/Identity-Profile schmal/vertagt | ADR §15-Philosophie; für Ein-Verkäufer-Stufe-1 nicht nötig |
+| Default-Surface je Workspace bei Migration | bewahrt heutiges 1-Zugang-Verhalten; 1→N ist additiv |
+| Cascade-Delete Surface mit Workspace | Surface ist Sub-Resource des Workspaces (ADR §5.1) |
+
+## 6. Offene Punkte
+
+- Surface-Scope-Claim: nur Achse anlegen (Stufe 1) vs. flächig erzwingen (später, ADR §16 Phase B).
+- `Callora.Workspace` → `Callora.Surface`-Umbenennung (ADR §14): vertagt, kein Stufe-1-Blocker.
+- Ob S3 Surface-Business-Events mitnimmt (Konsistenz zu EV1–EV4) — Kann-Entscheidung im Baustein.
diff --git a/ops/specs/2026-07-21-communication-foundation-clean-slate-design.md b/ops/specs/2026-07-21-communication-foundation-clean-slate-design.md
new file mode 100644
index 00000000..7a9c1602
--- /dev/null
+++ b/ops/specs/2026-07-21-communication-foundation-clean-slate-design.md
@@ -0,0 +1,257 @@
+# Communication-Foundation (Clean-Slate) — Design-Spec
+
+> Status: Design (Understanding gelockt inkl. API/WS-Pivot, wartet nicht mehr — Umsetzung läuft ab B0)
+> Datum: 2026-07-21 (rev. API/WS-first)
+> Nordstern: AiAgent (`/home/dbechstein/Downloads/ai-call-agent-plugin (1).md`)
+> Verwandt: [[callora-communication-rebuild-2026-07]], ADR-012/REV2 §10.1, CODE_STRUCTURE_RULES.md
+
+## 1. Ziel & Nicht-Ziele
+
+**Ziel:** Clean-Slate-Neuaufbau des System-Tier-Plugins `custom/static-plugins/Communication`
+als **Foundation** für Kommunikations-Anwendungen (Erstkunde AiAgent). Kein Softphone.
+
+**Primäre Consumer-Fläche = API (Shopware-App-analog):** REST-Control + **Webhooks** für
+Events + **WebSocket-Media-Stream** für Echtzeit-Audio (Twilio-Media-Streams-Stil). Damit
+lassen sich Consumer — insbesondere AI-Voice-Agenten — **out-of-process in beliebiger
+Sprache (Python/JS)** bauen, dort wo das STT/LLM/TTS-Ökosystem lebt.
+
+**Sekundäre Fläche = in-process .NET-Contract (Abstractions):** dünnes Vertrags-Assembly für
+tief integrierte .NET-Plugins (Flow-Actions, .NET-native Consumer). Bleibt erhalten, ist
+aber nicht mehr der Default-Weg für Agenten.
+
+**In-Scope (v1):**
+- Voll ausgebauter **Voice-Channel** über CalloraVoipSdk 4.6 (Inbound + Outbound).
+- **Media-Bridge:** SDK-RTP ↔ WebSocket; die Foundation trägt Pacing/Jitter/DTX (die
+ schwache RTP/Media-Fläche der SDK, Agent-Doc §7) **an einer** Stelle — der externe Agent
+ bekommt nur einen Audio-Chunk-Stream.
+- **REST-Control-API** (Accounts/Lines/Calls/Webhooks/Stream-Sessions) + **Webhook-Dispatch**.
+- **Domänen-/Persistenzmodell** `SipAccount → SipLine → Call/CallLog` + `WebhookSubscription`
+ + `MediaStreamSession` (§4), DSGVO-konform.
+- **In-process Abstractions** (sekundär) unter `src/Abstractions/` — eigenes Assembly.
+
+**Nicht-Ziele (v1):**
+- Kein Nicht-Voice-Channel (SMS/Messaging) — Abstraktion channel-ready, nur Voice gebaut.
+- Kein Softphone-UI, keine Queue/IVR-Builder.
+- Keine Recordings/Transkripte in der Foundation (nur Call-Metadaten).
+- Keine Migration installierter Alt-Daten (Alt-Plugin wird verschoben/archiviert, §3).
+- Keine Cloud-Lizenz-/Verwaltungsplattform (eigenes späteres System, `callora-store`).
+
+## 2. Understanding-Summary (gelockt)
+
+- **Was:** Communication-Foundation mit **API-first** (REST + Webhooks + WS-Media) als
+ primärer Consumer-Fläche; in-process .NET-Abstractions als sekundärer tiefer Pfad.
+- **Warum API-first:** AI-Ökosystem ist Python/JS; out-of-process ist sprachneutral,
+ sandboxed (passt zu Cloud/Geschäftsmodell) und markttypisch (Twilio Media Streams,
+ LiveKit, Pipecat, Vapi, Retell). REST allein trägt kein Echtzeit-Audio → WebSocket-Media.
+- **Für wen:** externe Agenten (primär) + tief integrierte .NET-Plugins (sekundär).
+- **Kernbeschränkung:** Clean-Slate inkl. Abstractions (bewusster Vertragsbruch); SDK 4.6
+ über lokalen NuGet-Feed (verdrahtet).
+- **AiAgent-Konsequenz:** wird eher ein **externer Python-Dienst** (oder dünnes .NET-Plugin
+ mit Python-Sidecar), der REST+WS nutzt — nicht zwingend ein in-process-.NET-Plugin.
+
+## 3. Verhältnis zum Alten & B0-Vorgehen (verschieben, nicht löschen)
+
+- Das alte Plugin wird **verschoben, nicht gelöscht**: `git mv` von altem **Impl** + **Dialer**
+ + ihren abhängigen **Testdateien** (inkl. der 3 Host-Infra-Fixture-Tests) nach
+ `custom/static-plugins/_archive/…` — vollständig erhalten, aus dem aktiven Build/der Suite
+ raus.
+- Die **alte Abstractions bleibt in B0 an Ort und Stelle bauen** (die CLI referenziert sie
+ für die ALC-Typidentität); **B1 ersetzt** sie durch die neue unter `src/Abstractions/` und
+ hängt die CLI-Referenz um.
+- Host-Infra-Coverage (Plugin-DB-Factory/-Migration/Curated-SP) fehlt übergangsweise und
+ kommt mit B3 (Persistenz) zurück.
+- Dialer wird archiviert; Re-Homing auf neue Verträge = Follow-up.
+
+## 4. Domänenmodell
+
+### 4.1 `SipAccount` — Provider-/Trunk-Verbindung
+Wie gehabt: Host, Port, Transport (`Udp|Tcp|Tls`), `Mode` (`Register|Trunk`), Credentials →
+`ISecretStore`, `MaxConcurrentCalls`, Media-/NAT-Optionen, `Enabled`.
+**Status (`SipAccountStatus`):** `Disabled·Connecting·Up·Degraded·Failed` (+LastError, LastChangeAt).
+
+### 4.2 `SipLine` — aufrufbare Identität unter einem Account
+`AccountId`, `Label`, `SipUri`/`Aor`, `PrimaryNumber?` (DID v1 an der Line), `Enabled`,
+`InboundRoutingTarget` (Flow-ID / Webhook-Subscription / Capability).
+**Status (`SipLineStatus`):** `Disabled·Unavailable·Available·Busy` (abgeleitet).
+**Monetarisierungs-Haken (später, nicht v1):** Line-Erstellung über einen **gate-baren**
+Service — Cloud kann Line-Anzahl per Entitlement begrenzen, self-hosted unbegrenzt. Limit-/
+Lizenz-Quelle liegt **außerhalb** (Cloud-/Store-Plattform), nicht Teil dieser Spec.
+
+### 4.3 `Call` (dynamisch) + `CallLog` (History)
+Laufzeit `IVoipCall`/`ICall`. `CallLog` (persistiert, DSGVO): `LineId`/`AccountId`,
+`Direction`, `RemoteParty` (**sensitiveField**), `LocalIdentity`, Zeiten, `DurationSeconds`,
+`Outcome`, `DisconnectCause`, `HandledBy`, `CorrelationId`. **Keine** Recordings/Transkripte.
+Retention konfigurierbar (Default 90 Tage), `IWorkspaceDataPurgeContributor`.
+
+### 4.4 `WebhookSubscription` — Consumer-Event-Abo (NEU, API-Fläche)
+`WorkspaceKey`, `ConsumerName`, `Url`, `SigningSecretRef` (→ `ISecretStore`, HMAC),
+`SubscribedEvents` (`call.ringing/answered/ended/dtmf/…`), `Enabled`. Zustellung mit
+Signatur + Retry/Backoff über die bestehende `IBackgroundJobQueue`.
+
+### 4.5 `MediaStreamSession` — WS-Stream-Bindung (NEU, API-Fläche)
+Bindet einen lebenden Call an den WebSocket-Stream eines externen Consumers.
+`CallId`, `WorkspaceKey`, `ConsumerRef`, `ConnectToken` (kurzlebig, einmalig, → Auth des WS),
+`Format` (`AudioFormat`), `Direction`, `StartedAt`/`EndedAt`, `Status`
+(`Pending·Active·Closed`). Nur Metadaten persistiert (kein Audio).
+
+## 5. Primäre Fläche — Media-Streaming-API (Twilio-Media-Streams-Stil)
+
+Drei Transporte, sauber getrennt nach Latenz-Anforderung:
+
+### 5.1 REST-Control-API (`IHostAdminApiExtensionContributor`)
+Request/Response, workspace-scoped, RBAC (`communication.*`-Permissions):
+- Accounts/Lines: CRUD + Live-Status + Re-Register.
+- Calls: `POST /communication/calls` (Outbound), `accept`/`reject`/`hangup`/`dtmf`,
+ History (paginiert).
+- Webhooks: CRUD der `WebhookSubscription`.
+- Stream-Session: `POST /communication/calls/{id}/stream` → liefert `ConnectToken` + WS-URL.
+
+### 5.2 Webhook-Events (out-of-process Push)
+Bei `call.ringing` (Inbound) POST an die abonnierten URLs, HMAC-signiert; Payload enthält
+Call-Metadaten **und** einen `ConnectToken`+WS-URL, mit dem der Consumer den Media-Stream
+attacht (analog Twilios ``). Weitere Events: `call.answered/ended/dtmf`,
+`line.registered/failed`, `account.up/down`. Zustellung + Retry über `IBackgroundJobQueue`.
+
+### 5.3 WebSocket-Media-Stream (`wss://…/communication/media/{connectToken}`)
+Bidirektionaler Audio-Transport; JSON-Rahmen mit base64-µ-law (Twilio-kompatibles Schema):
+- **inbound** (Server→Consumer): `{event:"media", payload:}`
+- **outbound** (Consumer→Server): `{event:"media", payload:}`
+- **control:** `{event:"start", …call/format…}`, `{event:"stop"}`, `{event:"mark", name}`
+ (Playback-Marker), `{event:"clear"}` (Outbound-Puffer flushen → **Barge-In**).
+Der **Media-Bridge** (Infrastructure) übersetzt SDK-RTP ↔ WS, taktet Outbound präzise
+(monotone Clock, kein `Task.Delay`), behandelt Jitter/DTX/Comfort-Noise. Der Consumer sendet
+nur Chunks + `clear` bei Barge-In.
+
+### 5.4 MCP — agenten-native Tool-Fläche (Callora-weit, Communication als Erstlieferant)
+Zusätzlich zu REST bekommt Callora einen **MCP-Server** (Model Context Protocol) als
+*standardisierte, LLM-native* Kontroll-/Tool-Fläche: jeder MCP-fähige Client (Claude Desktop,
+der AiAgent, andere LLM-Apps) kann Callora-Fähigkeiten als **Tools** aufrufen.
+- **Host-Ebene (eigene Plattform-Initiative):** MCP-Server-Framework + Contribution-Point
+ (`IMcpToolContributor`, analog `IHostAdminApiExtensionContributor`), damit **Plugins** Tools
+ beisteuern — wie heute REST-Endpoints/Flow-Actions/Events.
+- **Communication als Erstlieferant:** Call-Control-Tools (`place_call`, `accept`, `hangup`,
+ `get_call_status`, `list_call_history`) + Account/Line-Status als MCP-Resources.
+- **Kein Media über MCP:** MCP ist Request/Response-Tool-Calling; Echtzeit-Audio bleibt der
+ WebSocket. **MCP = was der Agent tut/liest · WS = Audio · Webhooks = Events.**
+- **Architektur:** MCP, REST und WS sind **Adapter über denselben Application-Services** (DDD:
+ transport-neutrale App-Schicht, Adapter außen) → kein Rework, MCP ist ein weiterer Adapter
+ unter `Api/Mcp/`.
+- **Sequenzierung:** Das Host-MCP-Framework ist eine **parallele Plattform-Initiative** (eigener
+ Spec/Plan). Communication baut seine App-Services v1 transport-neutral, exponiert zunächst
+ REST+WS+Webhooks; die MCP-Beisteuerung folgt, sobald das Host-Framework steht.
+
+## 6. Sekundäre Fläche — in-process .NET-Contract (`src/Abstractions/`)
+
+Eigenes dünnes Vertrags-Assembly (ALC-Typidentität) **unter `src/Abstractions/`**, damit am
+Plugin-Root nur `registry.json` + csproj liegen. Für tief integrierte .NET-Plugins.
+
+- **Behalten (channel-agnostisch):** `ICall`, `CallState/Direction/Target`,
+ `CallStateChangedEventArgs`, `IncomingCallEventArgs`, `CallSummary`, `ICommunicationChannel`
+ (+`ChannelHealth`), `ICommunicationChannelRegistry`, `CommunicationCapabilities`, Consent.
+- **Voice-Erweiterung:** `IVoiceChannel : ICommunicationChannel`, `IVoipCall : ICall`
+ (`OpenAudioAsync() → ICallAudioStream`), `ICallAudioStream` (`FrameReceived`+`SendAsync`),
+ `AudioFormat`. Dieselbe Media-Bridge speist beide Flächen.
+- **Verworfen:** `ICallEventStream/Subscription/CallStreamEvent` (→ Webhooks/Business-Events);
+ `ICallDirectory` (→ REST/`PlaceCallAsync`).
+- „Impl zusätzlich in den Default-Context laden?" bleibt eine **orthogonale** Hosting-Frage,
+ entschieden in B4 — die separate Abstractions zwingt der Impl keinen ALC auf.
+
+## 7. Architektur & Schichten (System-Tier, neues src/-Layout)
+
+```
+custom/static-plugins/Communication/
+├── registry.json # capabilities ["communication.voice"]
+├── Callora.Plugin.Communication.csproj # kompiliert src/** OHNE src/Abstractions; refs Abstractions + Core + Analyzer + CalloraVoipSdk 4.6 + EF/Npgsql
+└── src/
+ ├── CommunicationPlugin.cs # Composition Root
+ ├── Abstractions/ # eigenes Vertrags-csproj (sekundäre Fläche, §6)
+ ├── Domain/{Accounts,Lines,Calls,Webhooks,Streaming}/
+ ├── Application/
+ │ ├── Accounts/ Lines/ Calls/ # Ports, Coordinators, InboundRouter
+ │ ├── Streaming/ # MediaSessionService, Barge-In-/Mark-Logik
+ │ ├── Webhooks/ # WebhookDispatcher (über IBackgroundJobQueue), Signatur
+ │ └── Compliance/ # Purge-Contributor, Retention
+ ├── Infrastructure/
+ │ ├── Sdk/ # CalloraVoipSdk 4.6-Bridge (Registration, IVoipCall)
+ │ ├── Media/ # Media-Bridge SDK-RTP↔WS, G711/PCM, PacedSender, Jitter/DTX
+ │ ├── Persistence/ # CommunicationDbContext (plugin_communication), Migrations
+ │ └── Transport/ # WS-Handler, Webhook-HTTP-Client
+ └── Api/{Rest,WebSocket,Mcp}/ # Präsentations-Adapter über dieselben Application-Services
+```
+
+**Code-Konventionen:** nach **DDD-Schicht UND Feature** sortiert (Feature-Ordner je Schicht,
+z.B. `Application/Accounts`, `Domain/Streaming`), ein Typ pro Datei (CODE_STRUCTURE_RULES).
+Qualität aktiv an den refactoring.guru-Katalogen: **Smells vermeiden** (Long Method, Large
+Class, Feature Envy, Primitive Obsession, Data Clumps …), **Techniken anwenden** (Extract
+Method/Class, Move Method, Replace Conditional with Polymorphism …). [[callora-code-conventions-ddd-feature-refactoring]]
+
+**Media-Bridge = Kernrisiko** (SDK-RTP/Media ist die schwache Fläche, Agent-Doc §7): gekapselt
+in `Infrastructure/{Sdk,Media}`, trägt Pacing/Jitter/DTX; Application/Domain SDK-frei.
+
+## 8. Datenfluss (Inbound-Agent-Call, primärer Weg)
+
+1. SDK meldet Inbound-Call auf einer Line → `Call` (Ringing), `CallLog` angelegt.
+2. `InboundRouter` findet die `WebhookSubscription`(s) der Line → `WebhookDispatcher` POSTet
+ `call.ringing` (signiert) inkl. `ConnectToken`+WS-URL.
+3. Consumer (Python-Agent) antwortet/handelt: akzeptiert via REST (`accept`) und öffnet den
+ **WebSocket** mit dem `ConnectToken` → `MediaStreamSession` wird `Active`.
+4. **Media-Bridge**: inbound RTP → G.711-Frames → WS (`media`); outbound WS-`media` → präzise
+ getaktet → SDK-RTP. `clear` vom Consumer flusht den Outbound-Puffer (**Barge-In**).
+5. `hangup`/SDK-Ende → `call.ended`-Webhook, `MediaStreamSession` `Closed`, `CallLog`
+ finalisiert.
+
+*(Deep-Path .NET-Consumer nutzen statt Schritt 2–4 direkt `IVoipCall.OpenAudioAsync()`.)*
+
+## 9. Persistenz & DSGVO
+`CommunicationDbContext` (Schema `plugin_communication`) über `IPluginDbContextFactory`;
+Tabellen `sip_accounts`, `sip_lines`, `call_logs`, `webhook_subscriptions`,
+`media_stream_sessions`. Secrets (SIP-Passwörter, Webhook-Signing) nur als `…SecretRef` →
+`ISecretStore`. `CommunicationDataPurgeContributor` (RemoteParty anonymisieren, Logs kürzen).
+Consent: Recording nur bei `RecordingConsentState.Granted` (Recording ist ohnehin
+Consumer-Sache).
+
+## 10. Sicherheit
+- **WS-/Webhook-Auth:** `ConnectToken` kurzlebig, einmalig, call-gebunden; Webhooks
+ HMAC-signiert (Consumer verifiziert). Kein Dauer-Secret im WS-URL.
+- **Least-Privilege:** REST-API RBAC-/workspace-scoped; Consumer sehen nur ihre Calls/Lines.
+- **Out-of-process = Sandbox:** externe Agenten laufen außerhalb des Hosts (kein ALC-Zugriff).
+
+## 11. Fehlerbehandlung & Resilienz
+Registrierungsfehler → `Account.Status=Failed` + Event, kein Crash (SDK-Backoff). Webhook-
+Zustellung mit Retry/Backoff (Job-Queue). WS-Abbruch → Session `Closed`, Call bleibt (Consumer
+kann re-attachen, solange Call lebt). Concurrency-Limit → definierte Ablehnung.
+
+## 12. Teststrategie
+Contract-Tests der Abstractions (State-Machine, Registry, Audio-Duplex). WS-Protokoll-Tests
+(Frame-Roundtrip, `clear`/Barge-In, Auth via `ConnectToken`). Webhook-Signatur + Retry.
+Media-Bridge gegen Loopback/Fake-SDK. EF-Persistenz + Purge. Governance-Analyzer CAL0001–4.
+
+## 13. Sequenzierung (Bausteine) & Follow-ups
+
+| B | Inhalt | Status |
+|---|---|---|
+| **B0** | Grund freiräumen: Alt-Impl+Dialer+abh. Tests **verschieben**; Alt-Abstractions bleibt bauen | **läuft** |
+| **B1** | Neue Abstractions unter `src/Abstractions/` (Vertrag + Contract-Tests); CLI-Ref umhängen | Plan |
+| **B2** | Media-Streaming-API-Skelett: REST-Control + WS-Endpoint + Webhook-Modelle (ohne echtes SDK-Audio) | Plan |
+| **B3** | Domain + Persistenz (Accounts/Lines/CallLog/Webhooks/Sessions, DbContext, Migration, Purge) | Plan |
+| **B4** | SDK-Bridge + Media-Bridge (SDK-RTP↔WS, Pacing/Jitter), Registration-Lifecycle *(SDK-4.6-API-Discovery)* | Plan |
+| **B5** | Inbound/Outbound + Webhook-Dispatch + Barge-In + CallLog-Finalisierung; Flows/Consent | Plan |
+| **B6** | Admin-Surface (optional) + Härtung; Dialer-Entscheidung | Plan |
+
+**Follow-ups:** Dialer re-homen/verwerfen; SDK-Feed für CI; Number/DID-Entity; Impl-in-
+Default-ALC-Frage; Cloud-Line-Limit (extern).
+
+## 14. Entscheidungslog (Ergänzungen zur API/WS-Wende)
+
+| Entscheidung | Alternative | Warum |
+|---|---|---|
+| **API/WS-first** (REST+Webhooks+WS-Media), in-process sekundär | nur in-process .NET-Contract | AI-Ökosystem ist Python/JS; out-of-process sprachneutral+sandboxed; Twilio/LiveKit/Pipecat-Standard; Media-Härtung bleibt in der Foundation |
+| WebSocket für Audio (Twilio-Media-Streams-Schema) | REST / gRPC | REST trägt keine 20-ms-Frames; WS ist Marktstandard + Twilio-kompatibles Payload |
+| Abstractions bleibt separat, unter `src/Abstractions/` | in Impl-Assembly mergen | ALC-Typidentität + dünner Compile-Vertrag ohne SDK/EF-Ballast; Root-Sauberkeit |
+| Alt-Plugin verschieben statt löschen (inkl. Tests) | git rm | nichts geht verloren; History bleibt; Coverage kehrt mit B3 zurück |
+| SipAccount→SipLine→Call/CallLog, Status getrennt | Line=DID / Slot | an realen SIP-Systemen geerdet (siehe Vorgänger-Revision) |
+
+## 15. Annahmen
+- Audio v1 = G.711 µ-law 8 kHz/20 ms; Base64-JSON-WS-Frames (Twilio-kompatibel).
+- `ConnectToken` einmalig/kurzlebig; Webhook-HMAC über `SigningSecretRef`.
+- Retention-Default 90 Tage; `WorkspaceKey` = Mandantenachse.
diff --git a/ops/specs/2026-07-27-admin-onboarding-wizard-design.md b/ops/specs/2026-07-27-admin-onboarding-wizard-design.md
new file mode 100644
index 00000000..88e9b75c
--- /dev/null
+++ b/ops/specs/2026-07-27-admin-onboarding-wizard-design.md
@@ -0,0 +1,72 @@
+# Admin Onboarding Wizard — Design (P3, „Web-Install-Wizard")
+
+**Datum:** 2026-07-27 · **Status:** akzeptiert · **Scope:** Admin-Shell (frontend-only)
+
+## Understanding
+
+Ein frisch installierter Callora-Deploy hat nach dem Console-Install (P2) + `.env`
+genau: 1 Operator, 0 Workspaces, Communication-Plugin auto-aktiv. Der Onboarding-
+Wizard führt den ersten Operator nach dem Login zu einem nutzbaren Setup. Er ist
+**Onboarding nach Login**, kein Setup-Mode: die App ist bereits provisioniert, Secrets
+und Operator kommen aus `.env`/Startup — der Wizard fasst KEINE Secrets/Domain an.
+
+## Nicht-Ziele
+
+- Keine Secret-/`.env`-/Domain-Behandlung in der UI (bleibt Console-Install/P2).
+- Kein „unkonfiguriert booten"-Setup-Mode.
+- Keine Backend-Migration, kein neuer Endpoint (nutzt bestehende Admin-APIs).
+- Keine geräteübergreifende Persistenz des „gesehen/verworfen"-Status (Folgestufe).
+
+## Schritte (MVP)
+
+Willkommen → (1) ersten Workspace anlegen → (2) Plugins ansehen/aktivieren →
+(3) ersten SIP-Account (Communication) → (4) weiteren Operator einladen → Fertig.
+
+- **Workspace** wird INLINE im Wizard angelegt (Kernaktion, entsperrt `/`): minimaler
+ Create (workspaceKey, displayName, type) → `PUT /api/workspaces/{key}`.
+- **Plugins / SIP-Account / Nutzer** werden als geführte Schritte mit Status + Link auf
+ die bestehende Vollansicht (`/plugins`, `/extensions/communication`, `/users/new`)
+ gezeigt — keine Duplizierung vorhandener Views.
+
+## Schritt-Status (server-autoritativ, abgeleitet)
+
+- Workspace erledigt: `GET /api/workspaces` liefert ≥1.
+- Plugins erledigt: `GET /api/plugins/installed` enthält ≥1 aktives (Communication ist
+ auto-aktiv → i.d.R. schon erledigt).
+- SIP erledigt: `GET /api/ext/admin/plugins/communication/sip-accounts?workspaceKey=`
+ nicht leer.
+- Nutzer erledigt: `GET /api/users` liefert ≥2.
+
+Fortschritt = Anzahl erledigter Schritte / 4. „Abgeschlossen" = alle 4 erledigt.
+
+## Auslösung / Verbindlichkeit
+
+- **Auto einmal:** 0 Workspaces UND localStorage-Merker `callora.onboarding.autoShown`
+ nicht gesetzt → beim Login/Shell-Mount einmal Redirect auf `/onboarding`; Merker setzen.
+- **Danach Karte:** Dashboard zeigt „Erste Schritte"-Karte (Fortschritt x/4, öffnet den
+ Wizard) bis abgeschlossen ODER verworfen (`callora.onboarding.dismissed`).
+- Jederzeit überspringbar; kein Zwang.
+
+## Architektur (neues Modul `src/modules/onboarding/`)
+
+- `onboarding.ts` — Composable: lädt Status aus den APIs, kapselt die localStorage-Merker,
+ liefert `steps`, `completedCount`, `isComplete`, `shouldAutoRedirect()`, `markAutoShown()`,
+ `dismiss()`, `isDismissed`.
+- `OnboardingView.vue` — Wizard, Route `/onboarding` (unter der Auth-Shell). Schritt-Liste
+ mit Status-Badges; Workspace-Inline-Form; Links für die übrigen; „Fertig" → Dashboard.
+- `GettingStartedCard.vue` — Dashboard-Karte (Fortschritt, „Setup fortsetzen", „Verwerfen").
+- Router: `/onboarding`-Child-Route. Auto-Redirect-Hook (im `routeGuard` oder AppShell-Mount).
+- Dashboard: Karte einbinden, wenn `!isComplete && !isDismissed`.
+
+## Decision Log
+
+- **frontend-only + abgeleiteter Status + localStorage-Merker** (statt Backend-User-
+ Preference + Aggregat-Endpoint): kleinster tragfähiger Schnitt, keine Migration, Server
+ bleibt Wahrheitsquelle für „erledigt". Backend-Preference (geräteübergreifend) vertagt.
+- **Workspace inline, Rest verlinkt:** die eine gateway-Aktion im Wizard, sonst keine
+ Duplizierung vorhandener Views.
+
+## Tests
+
+Vitest für `onboarding.ts`: Status-Ableitung (fetch gemockt), Auto-Redirect-Logik
+(0 Workspaces + Merker), Dismiss/Complete. `vue-tsc` sauber.
diff --git a/ops/specs/2026-07-27-mcp-tool-framework-design.md b/ops/specs/2026-07-27-mcp-tool-framework-design.md
new file mode 100644
index 00000000..d8c5779e
--- /dev/null
+++ b/ops/specs/2026-07-27-mcp-tool-framework-design.md
@@ -0,0 +1,157 @@
+# MCP Tool Framework + Call-Control-Adapter — Design
+
+**Datum:** 2026-07-27
+**Status:** in Freigabe (Kontur konvergiert)
+**Kontext:** `ICallControlService` (Communication) bedient bereits in-process (DI) + REST/Webhooks.
+Es fehlt das **agenten-native Gesicht**: MCP (Model Context Protocol), damit externe AI-Agenten
+Call-Control als Tools konsumieren. „Ein Service, mehrere Gesichter" — MCP ist ein weiterer dünner Adapter.
+
+## Understanding
+
+- **Was:** Eine generische MCP-Server-Schale (Host) + ein neutraler Tool-Beitrags-Contract, sodass
+ Plugins Fähigkeiten als MCP-Tools an externe AI-Agenten exponieren. Erster Consumer: Communication-Call-Control.
+- **Warum:** Beachhead Voice-AI — externe Agenten (STT/LLM/TTS) steuern Calls über MCP ohne In-process-Kopplung.
+ Keine Doppel-Logik: die Tools rufen `ICallControlService`.
+- **Für wen:** out-of-process MCP-Clients / AI-Agenten.
+- **Kernprinzip:** dünner Adapter; **Transport-Schale = generische Host-Infra** (wie die REST/WS-Catch-alls),
+ **Tools = Inhalt im Plugin**. Kommerzialisierung liegt in einer **separaten, späteren Lizenz-Schicht**, nicht hier.
+
+### Non-Goals
+
+- Keine neue Call-Control-Logik (nutzt `ICallControlService`).
+- Nur Call-Control-Tools v1 — keine flächendeckende Plugin-Exposition.
+- Keine MCP-Resources/Prompts v1 (nur Tools). Live-`call.*`-Events laufen über Webhooks.
+- Kein stdio-Transport (nur HTTP).
+- **Kein Lizenz-/Kommerz-Gate in v1** — das Lizenz-Subsystem ist eine eigene, spätere Initiative (s. u.).
+ MCP-Adapter v1 verhält sich voll offen (Community).
+- **Keine OAuth-Authorization-Server-Seite** (Browser-Onboarding: `/authorize`+PKCE, Discovery, DCR) — gehört zur
+ späteren Account-/Portal-Initiative. v1 = **Resource Server**, Agenten nutzen vorab ausgestellte Operator-Bearer-Tokens.
+
+## Architektur — „Schale im Host, Inhalt im Plugin" (wie REST/WS)
+
+```
+Externer AI-Agent ──MCP / Streamable HTTP──▶ /mcp (Host, einmal beim Start gemountet, authentifiziert)
+ │
+ McpToolAggregator (Host — freie Infra, neben Webhooks)
+ · live McpServerTool-Collection
+ · synchron zu ICalloraPluginCatalog (activate/deactivate) → tools/list_changed
+ · pro Call: Auth → Workspace-Scope → RBAC-Permission → invoke
+ │ sammelt
+ IMcpToolContributor-Exporte (Core-Contract, SDK-neutral)
+ ▲
+ CommunicationMcpToolContributor (Plugin — der „Inhalt")
+ · place_call / hangup_call / get_call / list_recent_calls
+ · Handler rufen ICallControlService
+```
+
+**Warum die Schale im Host, nicht als hot-Plugin:** ASP.NET friert die Endpunkt-Tabelle nach `app.Build()`
+ein; Plugins laden über Hosted-Services **danach**. Kein Plugin (auch kein static) kann `MapMcp` mounten —
+genau wie Communications REST/WS-Mount host-seitig ist und das Plugin nur Routen beisteuert. Die Schale ist
+generisch/plugin-agnostisch und bedient dynamisch, was gerade aktiv ist → **Tools eines Plugins sind bei
+Aktivierung sofort live (kein Restart, kein Re-Mount, kein Verbindungsabbruch)**.
+
+## Entscheidungen
+
+1. **Transport: Streamable HTTP** über `ModelContextProtocol.AspNetCore` (1.4.1, offiziell, net8+), gemountet
+ unter `/mcp`. stdio zurückgestellt.
+2. **Neutraler Contract in Core:** `IMcpToolContributor` + `McpToolRegistration`. Plugins referenzieren das
+ MCP-SDK **nicht** — der Host übersetzt neutrale Registrierungen in SDK-`McpServerTool`s (wie
+ `HostAdminApiRouteRegistration` ASP.NET-neutral ist).
+3. **Dynamische Tool-Menge:** live Collection synchron zum Plugin-Katalog (activate → Tools rein + `list_changed`,
+ deactivate → raus). Konsistent mit Calloras Hot-Install-Modell.
+4. **Auth = OAuth-2.1-Resource-Server (MCP-Standard, RFC 9728).** Der MCP-Server validiert **Bearer-JWTs**:
+ `.AddJwtBearer` (Calloras Operator-Token — Issuer/Audience/SigningKey aus `BackendHostOptions`) +
+ `.AddMcp(ResourceMetadata)` (offizielles SDK, `McpAuthenticationDefaults`) → exponiert
+ `/.well-known/oauth-protected-resource` (Protected Resource Metadata) + `401`-`WWW-Authenticate`-Challenge mit
+ `resource_metadata`-Zeiger. `MapMcp("/mcp").RequireAuthorization()`.
+ **Pro Tool-Call** dann Callora-RBAC via `IHttpContextAccessor`→`HttpContext.User`: Workspace-Scope wie
+ `CallAdminScope` (token-gebundener `workspace_key` gewinnt; Plattform-Operator übergibt `workspaceKey`-Arg) +
+ Tool-`RequiredPermission` (`communication.calls.read/manage`). **Der Host scoped + prüft — der Plugin-Handler
+ bekommt den fertigen Workspace.**
+ **Authorization-Server-Seite ist NICHT hier** (Browser-OAuth: `/authorize`+PKCE, Discovery, DCR) — gehört zur
+ späteren Account-/Portal-Initiative. v1: Agenten nutzen ein **vorab ausgestelltes Operator-Bearer-Token**; strikte
+ per-Resource-`aud`-Bindung (Token-Audience = MCP-Resource-URL statt `callora-host-api`) als Follow-up.
+5. **Transport-Schale + Aggregator = freie Host-Infra** (neben Webhooks; Core ist bereits `Microsoft.NET.Sdk.Web`).
+ Das MCP-SDK ist internes Impl-Detail; der öffentliche Vertrag bleibt der neutrale `IMcpToolContributor`.
+6. **Kommerzialisierung NICHT über Entitlements, sondern über eine separate Lizenz-Schicht** (s. u.) — in v1
+ nicht verdrahtet.
+
+## Contract (Core, SDK-neutral)
+
+- `IMcpToolContributor { IReadOnlyList Tools { get; } }`
+- `McpToolRegistration(string Name, string Description, JsonElement InputSchema, string RequiredPermission,
+ Func> Handler)`
+ — **nur `RequiredPermission` (RBAC)**; kein Entitlement/Lizenz-Feld (das ist eine separate, spätere Achse).
+- `McpToolInvocation`: geparste Argumente (`JsonElement`), **bereits aufgelöster** `WorkspaceKey`, Caller-Principal.
+- `McpToolResult`: neutrales Ergebnis (Text-/JSON-Payload + `IsError`).
+
+## Tools v1 (Communication)
+
+| Tool | Permission | Args | Ergebnis |
+|---|---|---|---|
+| `place_call` | calls.manage | `{to, channelId?, displayName?}` | CallSnapshot |
+| `hangup_call` | calls.manage | `{callId}` | `{hungUp: bool}` |
+| `get_call` | calls.read | `{callId}` | CallSnapshot \| null |
+| `list_recent_calls` | calls.read | `{limit?}` | CallHistoryEntry[] |
+
+`workspaceKey`-Arg optional; nur für Plattform-Operatoren nötig.
+
+## Kommerzialisierung / Lizenzierung (separates, späteres Vorhaben)
+
+**Nicht Teil dieses Adapters.** Festgehalten als Nordstern:
+
+- Modell **à la Shopware**: jede Instanz (auch self-hosted **Community-Edition**) hat eine **Instanz-Identität**,
+ verknüpft mit einem **zentralen Callora-Konto/Portal**. Kommerzielle Fähigkeiten (z. B. „Agent-Access"/MCP-Tools)
+ werden **pro Kunde im Portal** freigeschaltet; die Instanz **validiert die Lizenz periodisch, offline-tolerant**.
+- **Getrennt von den internen Entitlements** (die interne Capability-Grants pro Workspace sind). Lizenz = externer,
+ account-gebundener Kommerz-Grant.
+- **Andockpunkt in MCP (additiv, später):** ein per-Aufrufer-Filter im `McpToolAggregator` + optionales `/mcp`-Gate,
+ gespeist vom Lizenz-Subsystem — blendet nicht-lizenzierte Tools pro Aufrufer aus. Derselbe freie Endpunkt zeigt
+ je Kunde andere Tools. Kein Redesign des Frameworks nötig.
+- **Monetarisierungshebel** (später): „Agent-Access"-Tier schaltet `/mcp` frei; High-Value-Tool-Plugins bezahlt;
+ Usage-Metering an Tool-Calls. Der Transport selbst bleibt frei (wertlos ohne Tools).
+
+## Slices
+
+### M1 — Host-MCP-Framework (freie Infra)
+- Package-Ref `ModelContextProtocol.AspNetCore`; `IMcpToolContributor` + `McpToolRegistration`/`McpToolInvocation`/
+ `McpToolResult` (Core); `McpToolAggregator` + Mount `/mcp` (Host-Composition) mit Auth+Scope+RBAC-Permission-Wrapper
+ + dynamischer Katalog-Sync + `tools/list_changed`.
+- Tests: Scope-Auflösung (token-bound vs. Arg), Permission-Deny, dynamisches Add/Remove spiegelt sich in der
+ Collection, `list_changed` gefeuert.
+
+### M2 — Communication-Tools (der Inhalt)
+- `CommunicationMcpToolContributor` (4 Tools über `ICallControlService`); Export in `CommunicationPlugin.StartAsync`;
+ Tests (je Tool Happy-Path + Arg-Validierung + Scope-Durchreichung).
+
+## Testing
+
+- **Aggregator:** Fake-Katalog + Fake-Contributor; Scope-Auflösung, Permission-Gate (Deny ohne Permission),
+ dynamisches Add/Remove, `list_changed`.
+- **Communication-Tools:** Fake-`ICallControlService`; je Tool Args→Service-Call→neutrales Ergebnis,
+ Permission-Tags korrekt, Workspace durchgereicht.
+- **E2E-Handshake (falls im Harness tragbar):** Host hochfahren + MCP-Client (SDK liefert einen) gegen `/mcp` mit
+ Token: Tools listen, `place_call` gegen Fake-Service.
+
+## Offene Risiken (bei Umsetzung an Paket-XML verifizieren)
+
+- Exakte SDK-API für dynamische Collection + `tools/list_changed`.
+- Wie ein Tool-Handler den authentifizierten `HttpContext`/Principal liest (SDK-`RequestContext`/`IHttpContextAccessor`).
+- Nebenläufigkeit der live Collection (gleichzeitige Katalog-Änderung + Client-`tools/list`).
+
+## Decision Log
+
+- **Transport-Schale im Host, nicht als Plugin** — ASP.NET-Endpunkt-Tabelle friert nach `app.Build()` ein; Plugins
+ laden danach. Gleiche Trennung wie REST/WS (Host mountet Schale, Plugin liefert Inhalt). Self-hosted-Port-Variante
+ verworfen: eigener Port + Auth-Nachbau, weicht vom Muster ab, verkauft die billigste Schicht.
+- **Kommerzialisierung über separate Lizenz-Schicht, nicht Entitlements** (User-Entscheid) — Lizenz = externer,
+ account-gebundener Kommerz-Grant (Shopware-Modell, auch CE); Entitlement = interner Workspace-Capability-Grant.
+ MCP-Transport bleibt freie Infra; Wert steckt in den Tools. v1 liefert offen aus; Lizenz-Gate dockt später additiv an.
+- **Dynamische Collection statt statisch** (User-Entscheid) — konsistent mit Hot-Install; Tools live bei Aktivierung,
+ kein Restart/Re-Mount/Verbindungsabbruch, weil nur die Tool-Liste mutiert (nicht der Mount).
+- **Neutraler Core-Contract statt SDK-Typen in Plugins** — hält Plugins vom MCP-SDK entkoppelt (wie `ICall` SDK-neutral).
+- **Host scoped, nicht der Handler** — eine Auth-Wahrheit (wie `PluginAdminExtensionEndpoints` + `CallAdminScope`).
+- **Auth = OAuth-2.1-Resource-Server (MCP-Standard), nicht hand-rolled** — SDK liefert `.AddMcp(ResourceMetadata)` +
+ Bearer-Validierung; RS validiert Calloras Operator-JWTs. Die AS-Seite (Browser-Onboarding/PKCE/DCR) ist bewusst
+ vertagt an die Account-/Portal-Initiative; v1 nutzt vorab ausgestellte Operator-Tokens. Naive „nur
+ `.RequireAuthorization()`"-Annahme verworfen: übersah PRM/`WWW-Authenticate`-Discovery.
diff --git a/ops/specs/2026-07-28-communication-webrtc-channel-design.md b/ops/specs/2026-07-28-communication-webrtc-channel-design.md
new file mode 100644
index 00000000..68e8fd6e
--- /dev/null
+++ b/ops/specs/2026-07-28-communication-webrtc-channel-design.md
@@ -0,0 +1,83 @@
+# WebRTC-Voice-Channel (Communication) — Design
+
+**Datum:** 2026-07-28
+**Status:** akzeptiert (Zuschnitt bestätigt), Umsetzung in Slices
+**Kontext:** Communication hat heute nur den SIP-Channel (`SdkVoiceChannel` über `IPhoneLine`). Für
+browser-basierte Echtzeit (Softphone in der Admin-Shell, Web-Voice-Agent) und als Fundament für ein
+späteres Videokonferenz-Plugin fehlt ein **WebRTC-Channel** — gleichberechtigt neben SIP, über den
+WebRTC-Stack des CalloraVoipSdk 4.6.0.
+
+## Understanding
+- **Was:** Ein `WebRtcVoiceChannel : IVoiceChannel` + `WebRtcCall : ICall` über die SDK-WebRTC-Peer-
+ Primitive, plus ein WebSocket-Signaling-Endpunkt (SDP/ICE) zwischen Browser und Callora.
+- **Warum:** channel-neutrale Abstractions (`ICall`/`ICommunicationChannel`) wurden bewusst dafür gebaut.
+ Ermöglicht Admin-Shell-Softphone + web-basierten Agenten und ist das geteilte Primitive fürs Konferenz-Plugin.
+- **Kernprinzip:** Communication liefert das **WebRTC-Peer-/Signaling-Primitive**; Media-Routing zwischen
+ Peers (Raum/SFU) gehört ins spätere Videokonferenz-Plugin, WebRTC↔SIP-Bridging ist ein eigener, vertagter Slice.
+
+### Non-Goals (bewusst NICHT hier)
+- **WebRTC↔SIP-Media-Bridge** (Opus↔µ-law-Transcoding, Browser→PSTN) — eigener, vertagter Slice; braucht Codec-Integration.
+- **Raum/SFU-Media-Routing** (N Peers) — gehört ins Videokonferenz-Plugin (teilt nur `IWebRtcClient`/`IPeerConnection`).
+- **Server-seitiger Media-Consumer** (Voicebot/Agent-Audio über `ICallAudioStream`) — später, wenn ein Consumer da ist.
+- Video — das SDK kann es (`EnableVideo`/`WithVideo`), aber v1 fokussiert Audio; Video-Optionen werden durchgereicht, nicht verdrahtet.
+
+## SDK-4.6.0-Fakten (verifiziert)
+- Setup: `services.AddCalloraWebRtc(Action)`; `WebRtcOptions`: AudioCodecs, VideoCodecs, EnableVideo,
+ IceServers (`IceServerConfiguration` Host/Port/Type/Transport/User/Pass), DtlsCertificate, LocalEndPoint, SimulcastLayers.
+- `IWebRtcClient`: `CreatePeer` → `IPeerConnection`; `Peers` (`IPeerConnectionManager` Active/Count).
+- `IPeerConnection` (rohes Transport-Primitive, KEIN Call-Modell):
+ - Signaling: `CreateOffer` (→`LocalDescription`), `SetRemoteDescriptionAsync(sdp,ct)`, `AddIceCandidateAsync(cand,ct)`,
+ `GatherCandidatesAsync(ct)`, `StartAsync(ct)`; Events `LocalIceCandidateDiscovered`, `ConnectionStateChanged`, `TrackReceived`.
+ - Media: `SendAudioAsync(ReadOnlyMemory,ct)`, `SendVideoFrameAsync(...)`, `AttachMediaTap`, `SendDtmfAsync`.
+ - `State`: `PeerConnectionState` { New, Connecting, Connected, Disconnected, Failed, Closed }.
+- Media orthogonal zum SIP-Pfad: `RemoteTrack.FrameReceived`→`EncodedFrame` (Payload/RtpTimestamp/IsKeyFrame), rohe
+ Encoder-Payload (Opus/VP8), kein Decoder im SDK. NICHT die SIP-`IMediaReceiver`/`IMediaSender`-Taps.
+
+## Architektur
+```
+Browser (RTCPeerConnection)
+ │ WS /ws/communication/webrtc/{token} (SDP-Offer/Answer + ICE-Candidates, JSON)
+ ▼
+WebRtcSignalingHandler (IWebSocketHandler) ── vermittelt Signaling ⇄ IPeerConnection
+ │
+WebRtcVoiceChannel : IVoiceChannel (wraps IWebRtcClient; CreatePeer je Session)
+ └─ WebRtcCall : ICall (wraps IPeerConnection; State-Mapping; Hangup=Close)
+ (Media-Ziel v1: offen/keins — Routing=Konferenz-Plugin, SIP-Bridge=vertagt)
+```
+
+## State-Mapping (`PeerConnectionState` → foundation `CallState`)
+- New / Connecting → `Connecting`
+- Connected → `Connected`
+- Disconnected / Failed / Closed → `Terminated` (Failed → `TerminationReason` Category `Failed`)
+
+## Slices
+- **S1 — WebRTC-Client-Setup (Foundation):** `AddCalloraWebRtc` in die Communication-Komposition; `WebRtcClientOptions`
+ (STUN/TURN/DTLS/Codecs aus Config, analog `VoiceClientOptions`) + Mapping auf `WebRtcOptions`; `IWebRtcClient` verfügbar.
+ Kein Channel/Call, nur der Client + Konfig. Tests: Options-Mapping.
+- **S2 — Adapter (Core):** `WebRtcCall : ICall` (wraps `IPeerConnection`, State-Mapping, `HangupAsync`=Close, DTMF; Accept/
+ Reject werfen `InvalidOperation` — WebRTC kennt kein Ringing-Accept, der Call entsteht per Signaling; `TerminationReason`
+ aus `Failed`/Close). `WebRtcVoiceChannel : IVoiceChannel` (Capabilities=[Voice], Health aus Peers/Client). Tests mit Fake-`IPeerConnection`.
+- **S3 — Signaling-Transport:** `WebRtcSignalingHandler : IWebSocketHandler` + `WebRtcSignalingContributor :
+ IHostWebSocketEndpointContributor` (Route `/ws/communication/webrtc/{token}`, Token-Authorizer wie der Media-Contributor).
+ JSON-Protokoll { type: offer|answer|candidate, sdp?, candidate? }; bidirektional Browser⇄`IPeerConnection`. Tests: Handler-Logik.
+- **S4 — Provisioning + Registry:** `WebRtcVoiceChannel` in `ICommunicationChannelRegistry` registrieren (per Workspace),
+ Export/Wiring im `CommunicationPlugin`. Tests: Provisioning.
+
+## Decision Log
+- **Rohes Peer-Primitive → eigener Adapter** (wie SIP `SdkCall`), nicht auf ein SDK-Call-Modell warten (gibt es nicht).
+- **Signaling ist App-Job über den vorhandenen WS-Contributor-Seam** — kein neues Transport-Framework; ein zweiter
+ Contributor neben dem bestehenden Media-Contributor.
+- **Media-Ziel bewusst offen in v1**: 1:1-Human-Call braucht ein Ziel (SIP-Bridge vertagt) oder Peer-Routing (Konferenz-Plugin).
+ v1 etabliert Peer + Signaling + Call-Control-Shell; das Routing kommt mit dem jeweiligen Consumer.
+- **Kein Video-Wiring in v1** (nur Optionen durchgereicht) — YAGNI bis zum Konferenz-Plugin.
+
+## Offene Punkte (bei Umsetzung/Consumer klären)
+- Codec-Strategie (Opus client-seitig; SIP-Bridge bräuchte Transcoding).
+- Peer-ID/Token-Binding Browser⇄SDK-Peer (Token wie beim Media-Contributor).
+- ICE-Gathering-Deadline (Trickle unbegrenzt vs. Timeout).
+- Multi-Workspace-Namespacing (IWebRtcClient singleton, Channels workspace-isoliert).
+
+## Testing
+Fast-Tests mit Fakes (`IPeerConnection`/`IWebRtcClient`): Options-Mapping (S1), State-Mapping + Call-Control (S2),
+Signaling-Handler-Protokoll (S3), Provisioning/Registry (S4). Realer Browser-Interop analog zum SDK-eigenen
+`WebRtcBrowserInteropTests` bleibt opt-in/späterer E2E (kein CI-Default).
diff --git a/ops/specs/2026-07-28-videoconference-plugin-design.md b/ops/specs/2026-07-28-videoconference-plugin-design.md
new file mode 100644
index 00000000..62e9cf63
--- /dev/null
+++ b/ops/specs/2026-07-28-videoconference-plugin-design.md
@@ -0,0 +1,297 @@
+# Videokonferenz-Plugin (Google-Meet-Klon) — Design
+
+**Datum:** 2026-07-28
+**Status:** akzeptiert (Design bestätigt), Umsetzung in Slices — **überarbeitet 2026-07-28** (workspace-gebundenes
+Zugangsmodell mit Gast-Lobby + Host-Admission; UI durchgängig Vue/Surface statt SSR/C#-HTML).
+**Kontext:** Erster echter WebRTC-Multi-Party-Konsument der Plattform und das Vorzeige-Plugin für die
+Extensibility (Surface-Views, Admin-Slot, Extension Points). Baut auf dem WebRTC-Voice-Channel-Primitive
+(Communication) NICHT auf — es ist eine eigenständige Vertikale, die das CalloraVoipSdk-WebRTC direkt nutzt.
+
+## Understanding
+- **Was:** Ein workspace-gebundenes `videoconference`-Plugin, das browser-basierte Videokonferenzen über einen
+ **serverseitigen SFU** (Selective Forwarding Unit) liefert: Einladungslink → Name-Formular → Raum mit
+ Video/Audio, Chat, Screensharing und (client-seitigem) Hintergrundblur.
+- **Warum:** Demonstriert die Plattform end-to-end (Surface-Vue für die Raum-App, Admin-Vue für die Verwaltung,
+ Host-WS-Seam fürs Signaling, Public-HTTP-Seam für Gäste, Plugin-Lifecycle) und ist der Beweis, dass ein Dritter
+ die Plattform *von außen* erweitern kann. Zugleich das erste vorzeigbare Produkt.
+- **Kernprinzip SFU:** **SFU ist der moderne Standard** für Multi-Party (Mesh skaliert nur bis ~4-6, MCU ist alt/teuer).
+ Der SFU leitet encoded Frames weiter (kein Decode/Re-Encode). Die generischen **Multi-Track-Peer-Primitive**
+ (Transceiver/Renegotiation/PLI) gehören ins SDK (Release 4.7.0, paralleler Track); der **SFU selbst**
+ (Raum, Forwarding-Policy, Teilnehmer) lebt im Plugin — exakt die SIPSorcery-Trennung (Lib = Multi-Track-Peer,
+ App = SFU).
+- **Kernprinzip Zugang (Meet/Teams/Zoom-Modell):** Alles ist **workspace-gebunden**. Zwei Wege in *denselben* Raum,
+ in *dieselbe* Vue-Raum-App:
+ 1. **Workspace-Nutzer** (authentifiziert): erreicht den Raum über die **workspace-gebundene Surface**. Je nach
+ Bypass-Policy direkt als **Host** oder **Teilnehmer**.
+ 2. **Gäste** (nicht eingeloggt, aber *Gast dieses Workspaces*): erreichen den Raum über einen **Einladungslink**,
+ der über die normale Workspace-Auflösung (Host/Pfad) denselben Workspace auflöst → **anonym erreichbare
+ „join"-Surface** → Name-Formular → **Lobby (knock)** → **Host lässt ein (Admission)** → Rolle **Gast**.
+ Der Unterschied zwischen beiden ist **Identität + Lobby-Gate**, nicht die App: beide laufen in derselben
+ Surface-Vue-Raum-App, mit dem Theme und dem Raum *dieses* Workspaces.
+
+### Non-Goals (bewusst NICHT hier)
+- **Mesh / P2P** und **Server-relayed Mesh** — verworfen zugunsten des echten SFU.
+- **Multi-Track-Peer + PLI/FIR-Senden** — SDK-Sache (Release 4.7.0), NICHT im Plugin. Das Plugin konsumiert
+ die Fähigkeit über den Media-Seam.
+- **Server-seitiges Compositing/Transcoding (MCU)** — das SDK ist transport-only, kein Decoder/Encoder.
+- **Externer SFU** (LiveKit/mediasoup/Janus) — widerspricht der „auf Basis CalloraVoipSdk"-Strategie.
+- **C#-gebautes HTML** — der frühere `HtmlRenderer`-Ansatz ist **verworfen**. Gast- wie Mitglieds-UI ist Vue/Surface.
+- **Workspace-unabhängige Gast-App** — der Gast ist workspace-gebunden (nur anonym), keine losgelöste öffentliche App.
+- **Recording/Moderation/Breakout-Räume** — spätere Ausbaustufen, nachdem der SFU-Kern steht.
+
+## Abhängigkeiten & Annahmen
+- **SDK 4.7.0 (paralleler Track, in Arbeit):** liefert Multi-Track-Peers (`AddTrack`/Transceiver), SDP-Renegotiation
+ und ausgehendes PLI/FIR (Keyframe-Request). Bis dahin ist der **Media-Seam gestubbt** — alles andere ist baubar.
+- **Gemergt auf main:** WebRTC-Voice-Channel (Communication) + SIP-Härtung. Der WebRTC-Signaling-**Härtungscode**
+ (Answer-Deadline, Trickle-Gate, TOCTOU-Peer-Claim) ist das Referenzmuster; für v1 im Plugin dupliziert
+ (nicht vorzeitig in eine geteilte Lib gezogen).
+- **Plattform-Seams (bereits vorhanden):**
+ - **`IHostWebSocketEndpointContributor`** — Raum-Signaling **und Lobby/Admission**.
+ - **`IHostAdminApiExtensionContributor`** — authentifizierte Admin-API-Routen **mit RBAC** (`PermissionKeys`,
+ `RequiredPermission` je Route) **und Navigation** (`NavigationItems`). Trägt die Raum-Verwaltung; voll verdrahtet.
+ - **`IHostPublicHttpEndpointContributor`** — anonyme **JSON**-Endpunkte (Einladung prüfen, Name → Pending-Session).
+ *PR offen — Beibehaltung ist eine offene User-Entscheidung.* Der Einladungstoken trägt den Workspace-/Raum-Scope,
+ sodass der anonyme Datenweg trotzdem workspace-gebunden bleibt.
+ - **Anonyme Plugin-Asset-Auslieferung** (`/plugin-assets/{pluginId}/app/{surface}/`), Manifest, `ui-chain`,
+ Theme-Tokens — die Vue-Bundles; Surface-Views registrieren sich über `window.calloraSurface`, Vue external.
+- **Surface-Modell (bereits vorhanden — das ist der Gast-Weg):** Ein Workspace hat **N `WorkspaceSurface`** auf
+ geteilten Daten, **jede mit eigener URL** (`PublicHost`/`PublicPathPrefix`), eigenem **`AccessMode`**
+ (`Public`/`Authenticated`/**`Mixed`**), eigenem **`TemplatePluginId`** und Theme (ADR-014 §5/§6.1). **`Mixed`** =
+ *öffentliche UND geschützte Routen auf derselben Surface* — genau das Meet-Modell: die Raum-Surface trägt den
+ öffentlichen Gast-Join **und** den geschützten Mitglieder-Bereich unter *einer* URL. **Kein neuer Plattform-Seam
+ nötig** — aber die per-Surface-Auflösung (Route → konkrete `WorkspaceSurface`) + das per-Surface-`AccessMode`-Gate
+ im Render-/Lade-Pfad sind noch nicht verdrahtet (im Code als „later phase" markiert; siehe die
+ „Plattform-Verdrahtung"-Zeile unten, C-Surface). Das ist ein **kleiner echter Workstream**, kein Seam-Neubau.
+- **Base-Template + Erweiterung pro Surface (bereits vorhanden):** `SurfaceShell.SpaRoot` ist das neutrale
+ **Grundgerüst** (ein Mount-Punkt, keine eigene UI); pro Surface erweitert/ersetzt ein Template-Plugin
+ (`TemplatePluginId`) es, Includes über `@bundleId/...` (`ISurfaceTemplateBundleProvider`). Das ist der
+ „Blöcke/Extending"-Mechanismus, den das Plugin vorführt — die Raum-App ist eine Surface-Erweiterung des Base-Templates.
+- **SSR (Nunjucks) + Vue (dynamisch) — Hybrid:** Die Surface-Shell wird **serverseitig via Nunjucks** gerendert
+ (`/surface/render` → `NunjucksSurfaceRenderer`), **Vue** übernimmt die **dynamischen Inhalte** (Live-Raum,
+ Video-Grid, Chat, Lobby). Das Plugin steuert die SSR-Seite als **Template-Bundle** bei: `index.njk` (Entry, mit
+ `extends`/`block`/`include`, cross-bundle `@id/path`) unter `plugin-assets//views/workspace`; die Plattform
+ rendert es mit **Theme-Tokens** (`{{ tokens. }}` → `--cal-*`). Das Plugin ruft `ISurfaceRenderer` **nicht
+ selbst** auf (es *liefert* Templates) — die Nicht-Erreichbarkeit von `Callora.Surface.Rendering` blockiert diesen
+ Weg also nicht. Die SSR-Shell bootet die Surface-Runtime (`/surface-app/surface.js`), die die dynamischen
+ Vue-Views (`window.calloraSurface`) einhängt.
+- **Plattform-Verdrahtung (kleiner, echter Workstream — im Code als „later phase" markiert):** `/surface/render`
+ löst heute nur **Workspace → Default-Surface** auf und gated am **workspace-weiten** `SurfaceAccessPolicy`. Die
+ **per-Surface-Auflösung** (Route → konkrete `WorkspaceSurface`) **+ per-Surface `AccessMode`-Gate** (Public/Mixed)
+ fehlt noch — genau das braucht die `Mixed`-Raum-Surface (öffentlicher Gast-Join + geschützter Mitgliederbereich
+ unter einer URL). Eigener Branch→PR in callora (C-Surface); **modellierte Phase fertigstellen, kein neuer Seam.**
+- **Admin-Shell** (Vue-3-SPA) mit Extension-Slot-Mechanismus (`extension.page.`, IIFE-Bundle, Vue external →
+ `window.CalloraAdmin.vue`) verfügbar — siehe Plugin-Admin-UI-Bundle-Muster.
+
+## Architektur
+
+### Verortung & Schichten
+Eigenständiges, **workspace-gebundenes** Plugin `custom/plugins/VideoConference/` (Drittanbieter-Tier —
+Vorzeige-Extensibility, KEIN System-Plugin, keine Communication-Kopplung). DDD+Feature-sortiert, ein Typ pro Datei:
+- **Domain** — `Room`, `Participant`, `ParticipantRole` (Host/Teilnehmer/Gast), `Invitation` (workspace-/raum-scoped),
+ `RoomToken`, `LobbyEntry`/Admission-Zustände, `RoomAccessPolicy` (Lobby-Bypass), Lebenszyklus.
+- **Application** — `IRoomService` (Raum anlegen/beenden), `IInvitationService` (Link mint/validieren),
+ `IRoomSessionMinter` (Session mit Admission-Zustand), `ILobbyService` (knock/admit/reject/kick),
+ `IRoomChatService`, `IRoomMediaRouter` (der Media-Seam), Signaling-Choreografie.
+- **Infrastructure** — EF-Persistenz (eigener DbContext, wie Communication), `IWebRtcClient`-Setup
+ (eigene SDK-WebRtc-Instanz), die konkrete `SfuRoomMediaRouter`-Impl (wartet auf SDK 4.7.0).
+- **Api** — WS-Contributor (Raum-Signaling + Lobby), Admin-API-Contributor (Verwaltung), Public-HTTP-Contributor
+ (Gast-JSON), Surface-View-Bundle(s) (`src/Resources/public/{surface}` — Mitglieds- und Gast-Raum-App),
+ Admin-UI-Bundle (`src/Resources/public/admin`).
+
+### Zugang, Rollen & Lobby
+- **Rollen** (`ParticipantRole`):
+ - **Host** — hat den Raum angelegt bzw. Workspace-Nutzer mit Host-Recht; steuert die Lobby (admit/reject/kick).
+ - **Teilnehmer** — authentifizierter Workspace-Nutzer, voller Medien-Teilnehmer.
+ - **Gast** — zugelassener anonymer Externer (über Einladungslink + Admission).
+- **Lobby / Admission:** Ein Teilnahmeversuch mündet in eine **Session mit Admission-Zustand**:
+ - `Admitted` — Workspace-Nutzer, der laut Bypass-Policy direkt eintreten darf (Host/Teilnehmer).
+ - `Pending` — Gast (bzw. jeder, den die Policy zum Anklopfen zwingt): landet in der **Lobby**, ein **Host**
+ bekommt den Knock (Broadcast an Host-Sockets) und **lässt ein oder ab**. Erst nach `admit` wird der Peer
+ erzeugt und der Raum betreten; `reject`/Timeout → Verbindung geschlossen.
+- **Bypass-Policy pro Raum** (`RoomAccessPolicy`, v1 zwei Werte):
+ - `WorkspaceTrusted` (Default): authentifizierte Workspace-Mitglieder treten direkt ein; **Gäste klopfen immer an**.
+ - `LockedToHost`: alle außer dem Host klopfen an (auch Workspace-Mitglieder).
+- **Lobby-Control lebt in der Host-Raum-View**, nicht im Admin-Slot: der Host ist selbst im Raum und sieht die
+ Klopfenden dort → kein Admin-seitiger Push-Kanal nötig. Der Admin-Slot bleibt für **Verwaltung** (anlegen/auflisten/
+ beenden, Policy setzen), nicht für Live-Admission.
+
+### UI (SSR-Nunjucks + Vue-dynamisch, kein C#-HTML)
+- **Admin-Slot (Vue-IIFE, `extension.page.videoconference`)** — Raum-Verwaltung über den **Admin-API-Seam**
+ (`IHostAdminApiExtensionContributor`): Räume anlegen/auflisten/beenden, Einladungslinks, Bypass-Policy.
+ Authentifiziert, RBAC-permission-gated, mit eigenem Navigationseintrag.
+- **Surface-SSR-Shell (Nunjucks)** — das Plugin liefert `index.njk` (Entry) + Includes als Template-Bundle
+ (`views/workspace`), das die **Base-Shell erweitert** (`extends`/`block`); die Plattform rendert serverseitig mit
+ Theme-Tokens (`{{ tokens. }}`). Trägt die statischen/ersten Teile: Raum-Rahmen, Einladungs-Landing,
+ Name-Formular-Gerüst — SEO- und First-Paint-fähig.
+- **Surface-Vue-View (dynamisch, `window.calloraSurface`, Vue external)** — die dynamische Raum-App (Video-Grid, Chat,
+ Lobby-Wartezustand, für den Host die **Admission-Kontrolle**, Screenshare-/Blur-Toggle). Wird von der SSR-Shell über
+ die Surface-Runtime (`/surface-app/surface.js`) in die gerenderte Seite eingehängt. **SSR = Rahmen/statisch,
+ Vue = dynamisch/live.**
+ - **Mitglieder** und **Gäste** laufen auf **derselben `WorkspaceSurface`** (`AccessMode = Mixed`, eigene URL,
+ `TemplatePluginId` = die Raum-App als Erweiterung der Base-Shell): Mitglieder über die geschützten Routen
+ (authentifiziert), Gäste über die öffentlichen Routen (anonym) — Theme = Surface-Theme. Ein Codebase, zwei
+ Einstiegskontexte (authentifiziert vs. anonym+Lobby), *eine* URL/Surface desselben Workspaces.
+
+### WebRTC-Andockung
+Das Plugin nutzt **CalloraVoipSdk.WebRtc direkt** (eigener `IWebRtcClient`), nicht den Communication-Voice-Channel.
+Der SFU braucht `IPeerConnection`-Level-Zugriff (`AttachMediaTap` + `SendVideoFrameAsync`), den der `ICall`-Level
+des Voice-Channels bewusst verbirgt. Callora ist **Offerer** (wie beim Voice-Channel): je Teilnehmer erzeugt der
+Server einen Peer.
+
+### Raum-Signaling (mit Lobby-Gate)
+Eigener WS-Endpunkt `/ws/videoconference/room/{connectToken}` über den Host-WS-Contributor-Seam. Ablauf pro Teilnehmer:
+1. Browser (Mitglied über Surface / Gast über Name-Formular) löst eine **Session** ein: `IRoomSessionMinter` mint
+ einen `connectToken` mit **Admission-Zustand** (`Admitted` | `Pending`) + Rolle + Workspace-/Raum-Scope.
+2. Browser öffnet den Signaling-WS mit dem Token → Authorizer konsumiert es (single-use/TTL).
+3. **Lobby-Gate:** Ist die Session `Pending`, hält der Handler die Verbindung im **Lobby-Zustand** (noch **kein**
+ Peer): Knock an die Host-Sockets broadcasten, auf `admit`/`reject`/Timeout warten. Bei `admit` → weiter zu 4,
+ sonst sauberes Schließen. Ist die Session `Admitted`, direkt zu 4.
+4. Server erzeugt **einen serverseitigen Peer**, tauscht Offer/Answer/ICE (gehärtetes Muster).
+5. Teilnehmer wird in die **Raum-Registry** aufgenommen; Join/Leave + Teilnehmerliste (mit Rollen) an alle gebroadcastet.
+6. Der Peer wird dem `IRoomMediaRouter` übergeben — **hier** hängt das Forwarding (siehe Media-Seam).
+
+```
+Browser (RTCPeerConnection, N Remote-Tracks)
+ │ WS /ws/videoconference/room/{token} (SDP/ICE + Raum-Choreografie + Chat + Lobby, JSON)
+ ▼
+RoomSignalingHandler ── Lobby-Gate (Pending→knock→admit) ── Peer-Lebenszyklus + Raum-Mitgliedschaft
+ │ │
+ │ ▼
+ │ IRoomMediaRouter ◄── Media-Seam (Stub jetzt, SFU nach 4.7.0)
+ ▼
+Room (Registry: N Participants mit Rolle, je 1 serverseitiger IPeerConnection)
+```
+
+## Der Media-Seam (die zentrale Entkopplung)
+`IRoomMediaRouter` (Application) trennt „Raum + Signaling + Lobby + Peer-Lebenszyklus" (jetzt baubar) von
+„Multi-Track-Frame-Forwarding" (wartet auf SDK 4.7.0):
+
+```
+interface IRoomMediaRouter
+ ParticipantJoined(roomId, participantId, IPeerConnection peer) // Server-Peer verdrahten
+ ParticipantLeft(roomId, participantId) // Taps/Tracks lösen
+ TrackPublished(roomId, participantId, TrackKind) // z.B. Screenshare an
+ TrackUnpublished(roomId, participantId, TrackKind)
+```
+
+- **`NoopRoomMediaRouter` (jetzt):** akzeptiert Join/Leave, routet aber kein Media. Ein serverseitiger Peer
+ pro Teilnehmer wird erzeugt und empfängt das Browser-Video (mit heutigem SDK möglich), aber nichts wird
+ weitergeleitet — sichtbar wird noch nichts, aber der gesamte Raum-/Signaling-/Lobby-/Choreografie-Pfad ist live
+ und testbar.
+- **`SfuRoomMediaRouter` (nach SDK 4.7.0):** je Teilnehmer-Peer via `AddTrack`/Renegotiation N-1 ausgehende
+ Tracks; `AttachMediaTap` auf jedem Sender → Frame-Forwarding an die Empfänger-Tracks; PLI beim Join.
+ Nur diese eine Klasse wird beim SDK-Release ergänzt — kein Umbau am Rest.
+
+## Zerlegung (jedes Slice = eigener Plan/Umsetzung)
+
+**Vorstufe (Ops, parallel):** `Callora-Production` auf den gemergten Stand ziehen (NuGet-Referenzen inkl.
+WebRTC-Channel). Unabhängig vom Plugin-Design.
+
+**Plattform-Verdrahtung (C-Surface, in callora):**
+- **C-Surface-Render-Gate — ERLEDIGT (PR offen, `feat/surface-per-surface-access`):** `/surface/render` löst jetzt die
+ konkrete `WorkspaceSurface` auf und gated auf deren **per-Surface `AccessMode`** (Public/Authenticated/**Mixed →
+ Shell anonym**), Kontext (SurfaceKey/SurfaceType/Locale/Theme) aus der Surface. Neuer Store-Resolver
+ `ResolveSurfaceByPublicRouteAsync`; `ResolveByPublicRouteAsync` regressionsgeprüft unverändert. Build 0/0,
+ 1039 Core + 29 Analyzer grün.
+- **C-Surface-ui-chain — FOLLOW-UP:** `ui-chain`/Asset-Auslieferung ist noch am workspace-weiten
+ `SurfaceAccessPolicy`. Im `Mixed`-Szenario kein Blocker (Workspace-Policy bleibt `Public`, per-Surface
+ `Authenticated` schützt Mitglieder-Surfaces am Render-Gate). Optionaler `surfaceKey`-Parameter + per-Surface-Gate
+ bei Bedarf. Der Public-HTTP-Seam (PR) liefert den anonymen JSON-Datenweg; das `WorkspaceSurface`-Modell (`Mixed`)
+ den Gast-UI-Weg.
+
+**Jetzt baubar (Multi-Track-unabhängig):**
+- **P1a — Plugin-Gerüst + Raum-Domäne:** `videoconference`-Plugin scaffolden; `Room`/`Participant`/`ParticipantRole`/
+ `Invitation`/`RoomToken` + EF-Persistenz + Plugin-Lifecycle/Exports + `IInvitationService` (Token mint/validieren).
+ *(erledigt: P1a auf dem Plugin-Repo)*
+- **P1b — Raum-Signaling-Choreografie (bis Seam):** WS-Endpunkt + Authorizer (Token single-use/TTL),
+ Raum-Registry, Peer-Erzeugung (1/Teilnehmer, heutiges SDK), Join/Leave/Teilnehmerliste-Broadcast,
+ Signaling-Protokoll; Peer-Übergabe an `IRoomMediaRouter` (Noop-Impl). Media-Forwarding NICHT hier.
+ *(erledigt: P1b + Härtung auf dem Plugin-Repo)*
+- **P-Lobby — Rollen & Admission:** `ParticipantRole` + `RoomAccessPolicy`; Admission-Zustand in der Session
+ (`Admitted`/`Pending`); **Lobby-Gate** im Signaling-Handler (Pending hält ohne Peer); Knock-Broadcast an Hosts;
+ Host `admit`/`reject`/`kick` als WS-Control-Messages → an den Lobby-Waiter geroutet; Rollen in der Teilnehmerliste.
+- **P2 — Join-Flow, Einladungslink & Gast-Zugang (SSR-Nunjucks + Vue):** Einladungslink (workspace-gebunden, löst
+ Workspace/Surface auf); **SSR-Shell** (`index.njk`, erweitert Base-Shell) mit Raum-Rahmen + Name-Formular-Gerüst;
+ **dynamische Vue-View** (Lobby-Wartezustand, später Raum) eingehängt; Token-Validierung + Name-Absenden über den
+ **Public-HTTP-Seam (JSON)**; `Pending`-Session-Mint. Läuft über die **`Mixed`-Raum-Surface** (öffentliche +
+ geschützte Routen). Ersetzt nur den verworfenen **C#-`HtmlRenderer`** (string-HTML) — SSR bleibt, aber via Nunjucks.
+ Braucht die **C-Surface**-Verdrahtung (per-Surface-Auflösung + `AccessMode`-Gate).
+- **P3 — Chat:** raum-gebunden über den WS-Seam (kein DataChannel im SDK); Verlauf optional persistiert.
+- **P4-Blur — Hintergrundblur:** browser-seitig (MediaPipe/WebGL auf dem lokalen Video vor dem Encoding); Frontend.
+- **P5 — Admin-UI (Vue):** Räume anlegen/auflisten/beenden + Bypass-Policy im Admin-Slot
+ (`extension.page.videoconference`) über den **Admin-API-Seam** (RBAC + Nav); IIFE-Bundle.
+
+**Wartet auf SDK 4.7.0 (Multi-Track):**
+- **P1c — SFU-Forwarding-Kern:** `SfuRoomMediaRouter` (Multi-Track-Peer + Frame-Routing + PLI).
+- **P4-Screen — Screensharing-Track:** zweiter Video-Track (getDisplayMedia) via Renegotiation durch den SFU.
+- **Multi-Video-Rendering** im Browser (mehrere Remote-Streams).
+
+**Startsequenz:** P1a → P1b (+ Noop-Router) → P-Lobby → P2 (+ C-Surface-Verifikation) → P3/P5 parallel-fähig →
+[SDK 4.7.0] → P1c/P4-Screen.
+
+## Extensibility-Demonstration (das eigentliche Vorzeige-Ziel)
+- **Surface-Vue-View(s):** die Raum-App als Surface-View (`window.calloraSurface`, Vue external), workspace-gebunden,
+ für Gäste anonym über die join-Surface — zeigt Surface-Komposition und die anonyme Gast-Erreichbarkeit.
+- **Admin-Slot:** die Raum-Verwaltung als Vue-IIFE-Bundle am `extension.page.`-Slot über den Admin-API-Seam
+ (RBAC + Nav) — zeigt das Plugin-Admin-UI-Bundle-Muster (Vue external → `window.CalloraAdmin.vue`).
+- **Extension Points / Host-Seams:** WS-Contributor (Signaling + Lobby), Admin-API-Route-Registration,
+ Public-HTTP-Contributor (Gäste), Business-Events (`room.participant.joined`, `room.guest.admitted` etc.) —
+ zeigt, dass ein Plugin die Plattform ohne Core-Änderung erweitert.
+
+## Decision Log
+- **Workspace-gebundenes Zugangsmodell (auch für Gäste), getragen von `WorkspaceSurface`** — der Gast ist *Gast
+ eines Workspaces* (anonym), nicht workspace-unabhängig. Getragen vom bestehenden Surface-Modell: eine
+ `WorkspaceSurface` mit **eigener URL**, `AccessMode = Mixed` (öffentliche + geschützte Routen) und `TemplatePluginId`
+ (Erweiterung der Base-Shell). Verworfen: „workspace-unabhängiger Public-App-Host" **und** die zwischenzeitliche
+ Annahme, es brauche einen neuen Plattform-Seam für per-Surface-anonymen-Zugang — das kann `Mixed` schon.
+- **Gast-Lobby + Host-Admission (Meet/Teams/Zoom-Modell)** — Gäste klopfen an, ein Host lässt ein/ab; Mitglieder
+ können per Bypass-Policy direkt eintreten. Lobby-Gate sitzt im Signaling-Handler (Pending-Session ohne Peer).
+- **Rollen Host/Teilnehmer/Gast + `RoomAccessPolicy` (WorkspaceTrusted/LockedToHost)** — minimal, YAGNI; weitere
+ Policies später.
+- **Lobby-Control in der Host-Raum-View, nicht im Admin-Slot** — der Host ist im Raum; spart einen Admin-Push-Kanal.
+- **SSR (Nunjucks) + Vue (dynamisch), `HtmlRenderer` verworfen** — kein **C#-gebautes** HTML; die SSR-Shell rendert
+ die Plattform aus dem Plugin-**Template-Bundle** (`.njk`, erweitert Base-Shell), Vue übernimmt die dynamischen
+ Raum-Inhalte. Das Plugin ruft `ISurfaceRenderer` nicht selbst auf → dessen Nicht-Erreichbarkeit blockiert nicht.
+- **Public-HTTP-Seam als anonymer JSON-Datenweg für Gäste** — Token trägt Workspace-/Raum-Scope, bleibt damit
+ workspace-gebunden. (Beibehaltung des PR ist offene User-Entscheidung.)
+- **Base-Template + Erweiterung pro Surface als Extensibility-Vorführung** — `SurfaceShell.SpaRoot` + `TemplatePluginId`
+ + `@bundleId`-Includes; die Raum-App ist eine Surface-Erweiterung des Base-Templates (der „Blöcke/Extending"-Kern
+ des ursprünglichen Auftrags).
+- **Kein neuer Plattform-Seam für Gast-UI** — `WorkspaceSurface.AccessMode = Mixed` deckt öffentliche + geschützte
+ Routen ab. Höchstens eine kleine Verifikation/Verdrahtung, dass der anonyme ui-chain/Asset-Pfad die per-Surface
+ `AccessMode` ehrt (C-Surface).
+- **Echter SFU statt Mesh** — SFU ist der moderne Standard; Mesh skaliert nur bis ~4-6. Preis: braucht das
+ Multi-Track-SDK zuerst. (Alternativen Mesh / relayed-Mesh geprüft und verworfen.)
+- **Multi-Track-Primitive ins SDK, SFU-Logik ins Plugin** — SIPSorcery-Trennung; hält das SDK generisch und die
+ Anwendungslogik draußen. SDK-Release 4.7.0 (MINOR — additive WebRTC-Fläche, kein 4.6.1-Patch).
+- **Eigenständiges Plugin (SDK direkt), keine Communication-Kopplung** — SFU (N-Party-Forwarding) ist ein anderes
+ Muster als der Voice-Channel (1:1-Call); direkter `IPeerConnection`-Zugriff nötig.
+- **Media-Seam `IRoomMediaRouter`** — entkoppelt den ganzen Nicht-Media-Bau vom SDK-Track; nur eine Klasse wird
+ beim SDK-Release ergänzt.
+- **Chat über WS-Signaling-Seam** — das SDK hat keinen WebRTC-DataChannel; der WS-Seam ist ohnehin da und robust.
+- **Blur/Screenshare client-seitig** — Blur ist lokale Video-Verarbeitung (kein Server-Media); Screenshare ist ein
+ zusätzlicher Client-Track, den der SFU wie jeden anderen forwardet.
+
+## Offene Punkte (bei Umsetzung/Slice klären)
+- **Modell des per-Surface anonymen Zugangs** (Companion): per-Surface-Policy vs. designierte Public-Surface vs.
+ Route-Allowlist — eigener kurzer Design-Pass.
+- **Identity-Resolution auf der join-Surface:** wie unterscheidet der Signaling-/Mint-Pfad ein authentifiziertes
+ Mitglied von einem anonymen Gast, wenn beide dieselbe Surface laden (Session-Cookie/JWT vs. reiner Einladungstoken)?
+- **Gast-Datenweg:** workspace-unabhängiger `/public/{pluginId}/…` (Token trägt Scope) vs. workspace-scoped
+ Endpoint — Konsistenz mit dem workspace-gebundenen Modell.
+- **Lobby-Timeout / Re-Knock / Kick-Semantik** (Wieder-Anklopfen nach Ablehnung?).
+- **Codec-Wahl für den SFU** (VP8 vs. H264; alle Teilnehmer denselben — kein Transcoding).
+- **Simulcast-Empfang** + Layer-Auswahl je Empfänger-Bandbreite (SDK 4.7.0-abhängig; v1 single-layer).
+- **Raum-Kapazität / Token-TTL / Rejoin-Verhalten.**
+- **Persistenz-Umfang des Chats** (flüchtig vs. Verlauf).
+- **Multi-Instance/Sticky-Routing** (ein Raum = ein Prozess in v1).
+
+## Testing
+- Fast-Tests mit Fakes (`IPeerConnection`/`IWebRtcClient`, wie im Communication-WebRTC-Test): Raum-Domäne
+ (Join/Leave/Kapazität), Rollen (Host/Teilnehmer/Gast), Token-Mint/-Consume (single-use/TTL, Admission-Zustand),
+ **Lobby-Gate** (Pending hält ohne Peer; admit → Peer+Join; reject/Timeout → Close), **Bypass-Policy** (Mitglied
+ direkt vs. Gast klopft), Signaling-Choreografie (Teilnehmerliste-Broadcast mit Rollen), Chat-Relay,
+ `NoopRoomMediaRouter`-Verdrahtung, Admin-/Invitation-Routen.
+- Gast-Surface: anonyme Surface-Load (ui-chain/Assets/Theme ohne 404) sobald der Companion-Seam steht.
+- `SfuRoomMediaRouter` (nach SDK 4.7.0): Forwarding-Schleife mit Fake-Multi-Track-Peers; realer Browser-Interop
+ als opt-in/späterer E2E (kein CI-Default).
diff --git a/ops/specs/2026-07-30-videoconference-p1c-sfu-design.md b/ops/specs/2026-07-30-videoconference-p1c-sfu-design.md
new file mode 100644
index 00000000..6c627096
--- /dev/null
+++ b/ops/specs/2026-07-30-videoconference-p1c-sfu-design.md
@@ -0,0 +1,209 @@
+# Videoconference P1c — SFU-Media-Router (Design)
+
+**Datum:** 2026-07-30
+**Status:** Entwurf zur Review
+**Repo:** callora-videoconference (Branch-Basis: `feat/sdk-4.7.0-migration`)
+**Vorbedingung erfüllt:** CalloraVoipSdk 4.7.0 (Multi-Track + Renegotiation + PLI), Migrations-Branch gemerged/gepusht.
+
+---
+
+## 1. Ziel
+
+Der letzte fehlende Baustein: echtes **Mehrparteien-Video** durch Ersetzen von `NoopRoomMediaRouter`
+durch `SfuRoomMediaRouter`. Der Server leitet encodierte Frames zwischen den Teilnehmern eines Raums
+weiter (Selective Forwarding Unit) — er decodiert/mischt/transcodiert **nicht**. Browser übernehmen die
+gesamte Codec-Arbeit (VP8). Ergebnis: Google-Meet-artige N-Wege-Konferenz.
+
+**Nicht-Ziele P1c:** Simulcast-Layer-Auswahl (bandbreitenadaptiv), aktive Sprecher-Erkennung,
+Server-Aufzeichnung, TURN-Server (Connectivity-Plugin), Track-Removal-Renegotiation beim Verlassen
+(Tile verschwindet via Roster). Diese sind Follow-ups (§9).
+
+---
+
+## 2. Verifizierte SDK-Grundlagen (aus Deep-Dive 4.7.0)
+
+Diese Fakten sind gegen Implementierung + Integrationstests belegt (siehe `voip/SFU_DOC_FINDINGS_4.7.0.md`):
+
+- **Renegotiation funktioniert end-to-end.** `AddVideoTrack`/`AddAudioTrack` nach Connect + erneutes
+ `CreateOffer`/`SetRemoteDescriptionAsync` wenden das Track-Delta auf die LIVE-Session an — kein
+ Transport/DTLS/ICE/SRTP-Rebuild (Test `WebRtcRenegotiationPeerToPeerTests`). ⇒ **Join-Anytime**, keine Max-Slots.
+- **Empfang:** `IPeerConnection.TrackReceived` → `RemoteTrack` (pro MID); `RemoteTrack.FrameReceived`
+ liefert `EncodedFrame` (Payload, RtpTimestamp, IsKeyFrame, Rid, Mid). **Callback synchron auf dem
+ Receive-Loop-Thread; Payload nur während des Callbacks gültig → kopieren vor async Fan-out.**
+- **Senden:** `IVideoTrack/IAudioTrack.SendFrameAsync(frame, rtpTimestamp)` — der übergebene RTP-Timestamp
+ wird 1:1 auf die Wire-Packets gestempelt (A/V-Sync bleibt erhalten). Fire-and-forget, keine Backpressure.
+- **PLI:** nicht automatisch. Downstream-PLI feuert `VideoKeyFrameRequested` am Server-Peer; der Router
+ muss selbst `RequestVideoKeyFrameAsync(...)` am Upstream-Peer aufrufen. **Impedanz:** das Event trägt
+ **keine MID** → der Router weiß nicht, welcher ausgehende Track die PLI auslöste (siehe §9/Findings).
+- **Signalling-Ops** (`CreateOffer`/`SetRemoteDescription`/`StartAsync`) sind **single-caller-serialisiert**
+ (HARD-C6) — pro Peer nie nebenläufig aufrufen. `SendFrameAsync` ist gegen `DisposeAsync` via Drain-Gate gehärtet.
+- **msid/StreamId:** ein ausgehender Track kann via `VideoTrackOptions.StreamId`/`AudioTrackOptions.StreamId`
+ einer MediaStream-Id zugeordnet werden → der Browser erkennt am `stream.id` die Quelle.
+
+---
+
+## 3. Topologie
+
+Der Server hält **einen `IPeerConnection` pro Teilnehmer** (schon so — ein Peer pro WS). Der SFU verdrahtet:
+
+```
+Teilnehmer P, Peer(P):
+ INBOUND : P's eigene Kamera+Mikro (TrackReceived auf Peer(P))
+ OUTBOUND : je 1 Video- + 1 Audio-Track pro ANDEREM Teilnehmer O
+ (Peer(P).AddVideoTrack/AddAudioTrack, StreamId = O.participantId)
+```
+
+Frame-Fluss: kommt ein Frame von P herein (Peer(P).TrackReceived→FrameReceived), wird die **kopierte**
+Payload an `Peer(O).outboundTrackFor(P)` jedes anderen O gesendet, mit dem Source-RtpTimestamp.
+
+Beispiel Raum {A,B,C}: Peer(A) hat Outbound-Tracks für B und C; A's Inbound-Frames gehen an
+Peer(B).outboundFor(A) und Peer(C).outboundFor(A). Symmetrisch für B, C.
+
+Server = **immer Offerer** (schon so: `RoomSignalingNegotiation.StartAsync` erzeugt das Offer, Browser
+antwortet). Renegotiation = der Server sendet bei Topologie-Änderung ein *weiteres* Offer; der Browser
+antwortet erneut. Kein Glare, weil nur der Server offert.
+
+---
+
+## 4. Lücke: Signalling-Seam für Renegotiation (P1c-1)
+
+Heute sendet `RoomSignalingNegotiation` **genau ein** Offer, und der Answer-Pfad ruft `StartAsync`
+(Transport-Start). Für den SFU fehlt:
+
+1. **`RenegotiateAsync(CancellationToken)`** auf `RoomSignalingNegotiation`: `CreateOffer()` →
+ `offer`-Frame senden (Kandidaten-Trickle-Gate ist bereits offen). Serialisiert gegen den Answer-Pfad.
+2. **`StartAsync`-Guard:** `StartAsync` nur beim **ersten** Answer; Renegotiation-Answers rufen nur
+ `SetRemoteDescriptionAsync`. (Flag `_started`.)
+3. **Signalling-Gate:** ein `SemaphoreSlim(1,1)` in der Negotiation, das `StartAsync` (initiales Offer),
+ `HandleAsync` (Answer/Candidate) und `RenegotiateAsync` serialisiert — HARD-C6 verlangt single-caller.
+4. **Router-Trigger:** der Handler übergibt dem Router beim Join einen per-Teilnehmer-Delegaten
+ `Func requestRenegotiation` (= `negotiation.RenegotiateAsync`). Dazu wird
+ `IRoomMediaRouter.ParticipantJoinedAsync` um diesen Parameter erweitert; `NoopRoomMediaRouter` ignoriert ihn.
+
+Der Answer der Renegotiation fließt über den bestehenden Receive-Loop → `negotiation.HandleAsync` →
+`SetRemoteDescriptionAsync` auf denselben Peer — der Router muss den Answer **nicht** selbst behandeln.
+
+---
+
+## 5. `SfuRoomMediaRouter` (P1c-2)
+
+Ersetzt `NoopRoomMediaRouter` (gleicher `IRoomMediaRouter`-Seam, + `requestRenegotiation`-Parameter).
+
+### 5.1 Datenstruktur
+- `rooms: ConcurrentDictionary`
+- `Room`: `participants: Dictionary` unter einem `lock` pro Raum (Topologie-Mutationen sind selten, Frame-Forwarding liest lock-frei über Snapshots).
+- `ParticipantEntry`:
+ - `IPeerConnection Peer`
+ - `Func RequestRenegotiation`
+ - `outbound: Dictionary` (Ziel-Quelle `sourceParticipantId` → `(IVideoTrack, IAudioTrack)`) — die Tracks, über die **dieser** Peer die Medien der Quelle rendert.
+ - `remoteTrackHandles` / Event-Subscriptions für Cleanup.
+
+### 5.2 Join (N tritt Raum mit {E…} bei)
+Unter dem Raum-`lock`:
+1. `ParticipantEntry(N)` anlegen.
+2. Für jedes bestehende E:
+ - `Peer(E).AddVideoTrack(StreamId=N)` + `AddAudioTrack(StreamId=N)` → `E.outbound[N]`.
+ - `Peer(N).AddVideoTrack(StreamId=E)` + `AddAudioTrack(StreamId=E)` → `N.outbound[E]`.
+3. `Peer(N).TrackReceived` abonnieren → pro `RemoteTrack` `FrameReceived` → **Copy-on-receive** → an
+ `consumers(N)` senden (= alle O mit `O.outbound[N]`), passenden Kind (video/audio), Source-Timestamp.
+4. `Peer(N).VideoKeyFrameRequested` abonnieren → Keyframe von **allen** aktuellen Upstreams von N anfordern
+ (`Peer(E).RequestVideoKeyFrameAsync()` für alle E) — grob, aber korrekt (Event trägt keine MID, §9).
+5. **Renegotiation auslösen:** `N.RequestRenegotiation()` (neue Tracks für alle E) und jedes betroffene
+ `E.RequestRenegotiation()` (neuer Track für N). Fire-and-forget mit Fehler-Logging; nicht unter dem lock awaiten.
+6. **Initiale Keyframes:** direkt nach Join Keyframe von allen bestehenden E anfordern, damit N schnell
+ Intra-Frames für die neuen Tiles bekommt.
+
+### 5.3 Frame-Forwarding
+- Handler synchron & nicht-blockierend: `var copy = frame.Payload.ToArray();` dann pro Consumer
+ `_ = targetTrack.SendFrameAsync(copy, frame.RtpTimestamp ?? 0, ct)` (fire-and-forget), Fehler pro
+ Consumer isoliert (try/catch, weiterforwarden). Video an Video-Track, Audio an Audio-Track.
+- Consumer-Set wird pro Frame live aus dem Raum-Snapshot gelesen (neue Teilnehmer erscheinen automatisch).
+
+### 5.4 Leave (N verlässt)
+Unter dem Raum-`lock`:
+1. `Peer(N).TrackReceived`/`VideoKeyFrameRequested` abmelden; N aus `participants` entfernen.
+2. Forwarding N→andere und andere→N stoppt automatisch (N ist raus, `consumers`/`outbound` entfernt).
+3. `Peer(N).DisposeAsync()` (Ownership lag beim Router).
+4. Die bei E verwaisten `E.outbound[N]`-Tracks bleiben inert (senden nichts mehr); der **Roster-Broadcast**
+ des Handlers lässt den Browser N's Tile entfernen. Track-Removal-Renegotiation = Follow-up (§9).
+
+### 5.5 Nebenläufigkeit / Lifecycle
+- Topologie-Mutation (Join/Leave) unter Raum-`lock`; Frame-Forwarding lock-frei über Snapshot.
+- `SendFrameAsync` ist gegen Peer-Dispose gehärtet (Drain-Gate) → ein Frame, der auf einen gerade
+ verlassenden Peer trifft, wirft `ObjectDisposedException`, wird geschluckt.
+- `AddVideoTrack/AddAudioTrack` sind thread-safe (lock-frei) und dürfen mid-call aufgerufen werden.
+
+---
+
+## 6. Frontend (P1c-3)
+
+- **Wiederholte Offers:** der Room-Controller muss jedes eingehende `offer`-Frame behandeln
+ (`setRemoteDescription(offer)` → `createAnswer` → `answer` senden), nicht nur das erste. Der Browser-
+ `RTCPeerConnection` verarbeitet Renegotiation nativ; `ontrack` feuert für jeden neuen Remote-Track.
+- **N Remote-Tiles:** `ontrack` → `event.streams[0].id` = Source-`participantId` (Server setzt StreamId).
+ Tile pro Remote-MediaStream, DisplayName aus dem Roster (participantId→Name). Video- und Audio-Track
+ desselben Streams gehören zu einem Teilnehmer (gleiche StreamId).
+- **Tile-Removal:** bei Roster-Update ohne Teilnehmer X → dessen Tile entfernen (deckt Leave ab).
+- Bestehende lokale Vorschau/Controls/Chat/Lobby bleiben unverändert.
+
+---
+
+## 7. Slicing & Akzeptanzkriterien
+
+Jede Slice: eigener Branch (stacked), DEV → unabhängiger Reviewer → Findings fixen → Gate (C# 0/0 +
+volle Suite; Frontend vue-tsc + vitest + Build) → Push → PR.
+
+- **P1c-1 (Signalling-Seam):** `RenegotiateAsync` + `StartAsync`-Guard + Signalling-Gate;
+ `IRoomMediaRouter.ParticipantJoinedAsync` um `requestRenegotiation` erweitert; Noop ignoriert es.
+ *Akzeptanz:* zweites Offer geht über die WS raus; Reneg-Answer ruft nicht erneut `StartAsync`; nebenläufiges
+ Offer/Answer serialisiert; bestehende Signalling-Tests grün; neuer Test für RenegotiateAsync + Guard.
+- **P1c-2 (`SfuRoomMediaRouter`):** Topologie/Forwarding/PLI-Bridge/Leave gemäß §5; in `VideoConferencePlugin`
+ Noop→Sfu tauschen.
+ *Akzeptanz:* Join fügt Outbound-Tracks + Subscriptions korrekt hinzu und triggert Renegotiation aller
+ Betroffenen; Frames werden kopiert und an alle Consumer geforwardet (Timestamp durchgereicht); Leave räumt
+ auf und disposed den Peer; Unit-Tests mit `FakePeerConnection` (Multi-Track-Fakes existieren bereits) für
+ Join-2/3-Wege, Forwarding-Fan-out, Leave-Cleanup, PLI-Bridge.
+- **P1c-3 (Frontend):** wiederholte Offers + N-Tile-Rendering + Track→Teilnehmer-Mapping + Tile-Removal.
+ *Akzeptanz:* vitest deckt Mehrfach-Offer-Handling, ontrack→Tile-Mapping via StreamId, Roster-getriebenes
+ Tile-Removal ab.
+
+---
+
+## 8. Teststrategie
+
+- **P1c-1/2:** reine Unit-Tests gegen `FakePeerConnection`/`FakeVideoTrack`/`FakeAudioTrack` (bereits im
+ Migrations-Branch) — deterministisch, kein echtes WebRTC. Fake erfasst AddedTracks, KeyFrameRequests,
+ gesendete Frames pro Track; Tests prüfen Fan-out-Ziele, Timestamp-Durchreichung, Copy (kein Alias),
+ Renegotiation-Trigger-Zählung, Leave-Cleanup.
+- **P1c-3:** vitest mit injizierten `RTCPeerConnection`-Fakes (Muster wie bestehende media-session-Tests).
+- **Manuelle E2E:** 3 echte Browser gegen eine Dev-Instanz — separat, nicht Teil des Merge-Gates
+ (SDK-N-Wege-Interop ist noch nicht gegen echte Browser breit validiert, RELEASE_NOTES 4.7.0).
+
+---
+
+## 9. Bewusste Deferrals / Impedanzen
+
+- **PLI ohne MID:** `IPeerConnection.VideoKeyFrameRequested` trägt keine MID → keine gezielte Upstream-PLI;
+ P1c fordert Keyframe von allen Upstreams an (grob, korrekt). Gezielt erst, wenn das SDK die MID am Event
+ surfaced → SDK-Wunsch (in `SFU_DOC_FINDINGS_4.7.0.md` als B4 vermerkt).
+- **Track-Removal beim Leave:** inerte Outbound-Tracks bleiben; Tile verschwindet via Roster. Sauberes
+ Track-Removal via Renegotiation später.
+- **Simulcast-Layer-Auswahl / bandbreitenadaptives Forwarding** (`RecommendedBitrateChanged`, `frame.Rid`):
+ P1c leitet den Einzelstream weiter; Layer-Selektion pro Empfänger ist ein Skalierungs-Follow-up.
+- **Aktive-Sprecher / Audio-Selektion:** P1c forwardet alle Audio-Tracks; Server-seitige Sprecherauswahl später.
+- **Fan-out-Ordering:** fire-and-forget Sends in Aufrufreihenfolge; falls Reordering auftritt, per-Consumer-Queue als Follow-up.
+
+---
+
+## 10. Decision Log
+
+| # | Entscheidung | Alternative | Grund |
+|---|---|---|---|
+| D1 | Server ist immer Offerer; Renegotiation = Server re-offert | Perfect-Negotiation mit beidseitigem Offer | Kein Glare, minimal, Browser bleibt reiner Answerer (schon so) |
+| D2 | Router-Trigger via `requestRenegotiation`-Delegat in `ParticipantJoinedAsync` | separater `IParticipantRenegotiator` im Registry | Seam bleibt in der Media-Abstraktion, minimale Fläche |
+| D3 | `StartAsync` nur beim ersten Answer (Flag) | jedes Answer startet Transport | Zweites StartAsync wäre falsch (Transport läuft schon) |
+| D4 | Ein Outbound-Track-Paar pro (Empfänger, Quelle) | ein gemischter Track / SSRC-Multiplex | SDK ist transport-only; ein Track pro Quelle = klare msid-Zuordnung |
+| D5 | Track→Teilnehmer-Mapping via `StreamId = sourceParticipantId` | eigenes App-Signalling-Mapping | Nutzt native msid; Browser bekommt Quelle ohne Zusatzframe |
+| D6 | PLI bei jeder Downstream-Anforderung an alle Upstreams | gezielt per MID | Event trägt keine MID; throttled → akzeptabel |
+| D7 | Leave lässt inerte Tracks, Tile weg via Roster | Track-Removal-Renegotiation sofort | Kleiner, korrekt; Removal-Reneg ist Zusatzkomplexität |
+| D8 | Copy-on-receive + fire-and-forget Fan-out | Payload direkt weiterreichen / await | Payload nur im Callback gültig; Handler darf nicht blockieren |
diff --git a/ops/spikes/2026-07-17-id-value-objects-spike.md b/ops/spikes/2026-07-17-id-value-objects-spike.md
new file mode 100644
index 00000000..7928895b
--- /dev/null
+++ b/ops/spikes/2026-07-17-id-value-objects-spike.md
@@ -0,0 +1,49 @@
+# Spike: ID-Value-Objects — Reibungsmessung (2026-07-17)
+
+**Frage:** Wie teuer ist die Grenzen-Reibung eines ID-Value-Objects im .NET-10-Stack
+wirklich? (Entscheidungsgrundlage: `PluginId`/`TenantKey`/`WorkspaceKey` als Typen —
+ChatGPT-Empfehlung — vs. `string` bleiben — R2-Entscheidung.)
+
+**Aufbau:** Isoliertes Wegwerf-Web-Projekt (`/tmp/id-vo-spike`, net10.0), ein `WorkspaceKey`
+in zwei Varianten durch vier Grenzen gezogen:
+- **Variant A — Vogen 8.0.6** (Source-Generator, MIT-Lizenz, net10-kompatibel): `[ValueObject(conversions: SystemTextJson | EfCoreValueConverter)]`
+- **Variant B — DIY** (`readonly record struct` + handgeschriebener `IParsable`, `JsonConverter`, `ValueConverter`)
+
+## Ergebnis pro Grenze
+
+| Grenze | Vogen (naiv) | DIY (naiv) |
+| --- | --- | --- |
+| Build | ✅ 0 Fehler | ✅ 0 Fehler |
+| EF persist + read-back | ✅ 1 Zeile `HasConversion` | ✅ 1 Zeile + 4-Zeilen-Converter-Klasse |
+| EF query-by-value (`.Where(d => d.Key == x)`) | ✅ zu SQL übersetzt | ✅ zu SQL übersetzt |
+| Minimal-API Route-Binding (`{key}`) | ✅ **automatisch** (Vogen generiert `IParsable`) | ✅ ~10 Zeilen `IParsable` von Hand |
+| JSON body round-trip | ✅ als `string` | ✅ als `string` (Converter + Attribut) |
+| OpenAPI Route-Param-Schema | ✅ `type: string` | ✅ `type: string` |
+| OpenAPI Body-Property-Schema | ⚠️ `{}` (leer) | ⚠️ `{}` (leer) |
+
+## Erkenntnisse
+
+1. **Die Reibung ist gering — geringer als die R2-Abwägung befürchtete.** Alle vier
+ Grenzen funktionieren; mit Vogen weitgehend aus einem Attribut heraus (EF-Converter,
+ JSON-Converter UND Route-Binding generiert).
+2. **Der einzige echte Fallstrick ist für beide Varianten identisch:** das OpenAPI-Body-
+ Property-Schema erscheint als leeres `{}` statt `type: string` (der .NET-OpenAPI-Generator
+ sieht den Struct hinter dem JsonConverter nicht). Fix = **ein einmaliger Schema-Transformer**
+ (~15 Zeilen, global, nicht pro Typ) — kein Blocker.
+3. **Vogen vs. DIY:** Vogen ≈ 1 Attribut-Zeile/Typ; DIY ≈ 20 Zeilen Boilerplate/Typ, aber
+ keine Dependency. Bei mehreren ID-Typen gewinnt Vogen deutlich an DX.
+
+## Konsequenz für die Entscheidung
+
+- Das Argument **„Grenzen-Reibung zu hoch"** (Hauptgegengrund gegen VOs bei R2) ist
+ **widerlegt.** Value-Objects sind im .NET-10-Stack tragbar.
+- Übrig bleiben als echte Faktoren: (a) einmaliger **Migrationsaufwand** der ~1850
+ bestehenden `string`-Nutzungen (der Spike testete nur *einen* Typ auf grüner Wiese —
+ die Massen-Migration der Call-Sites ist ungemessen), (b) **Vogen als Dependency**
+ (Bau-vs-Kauf; aktuell MIT, aktiv), (c) **Peer-Untypik** (kein .NET-Portal nutzt ID-VOs).
+- **Neue Surface-IDs** (SurfaceKey/RealmId/TemplateId): grüne Wiese, kein Migrationsaufwand,
+ Reibung gering → klarer Fall **für** VOs bei Entstehung.
+- **Bestehende drei Kern-IDs**: Entscheidung hängt jetzt nur noch an Migrationsaufwand vs.
+ Verständlichkeitsgewinn — nicht mehr an Reibung.
+
+Spike-Code verworfen (`/tmp`, nicht eingecheckt).
diff --git a/src/Administration/Api/BackendUserApiResponse.cs b/src/Administration/Api/BackendUserApiResponse.cs
index 99b9e4f6..fc9ff271 100644
--- a/src/Administration/Api/BackendUserApiResponse.cs
+++ b/src/Administration/Api/BackendUserApiResponse.cs
@@ -1,10 +1,20 @@
namespace Callora.Administration.Api;
+///
+/// Whether the account is deactivated: it keeps its data and memberships but
+/// authenticates nowhere and has its live sessions rejected (#104).
+///
+///
+/// Whether repeated failed sign-ins currently block authentication. Clears itself
+/// when the lockout window elapses.
+///
public sealed record BackendUserApiResponse(
string ExternalId,
string? Email,
string? DisplayName,
bool HasPassword,
string? PasswordHashAlgorithm,
+ bool IsDisabled,
+ bool IsLockedOut,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc);
diff --git a/src/Administration/Api/PluginAdminExtensionEndpoints.cs b/src/Administration/Api/PluginAdminExtensionEndpoints.cs
index 978cd8f3..f7f1b88e 100644
--- a/src/Administration/Api/PluginAdminExtensionEndpoints.cs
+++ b/src/Administration/Api/PluginAdminExtensionEndpoints.cs
@@ -69,24 +69,36 @@ private static async Task HandlePluginAdminRouteAsync(
return Results.Forbid();
}
- // A permitted caller still only reaches the plugin when it is effectively
- // available in the caller's workspace (REV2 §13): an entitlement lapse,
- // missing capability, unhealthy runtime or inactive workspace returns 403
- // rather than routing into a plugin that should be dark. Ordered after the
- // permission check so unavailability is never disclosed to callers who
- // lack the permission. Platform operators (WorkspaceKey == null) carry no
- // per-workspace scope and so are not gated, mirroring the API data source.
- var workspaceKey = workspaceScope.WorkspaceKey;
- if (!string.IsNullOrWhiteSpace(workspaceKey) &&
- httpContext.RequestServices.GetService() is { } availabilityEvaluator)
+ // The effective workspace — the caller's bound one, or the one a platform
+ // operator selected via ?workspaceKey=. Resolving it here, before the
+ // availability gate, is the point of #109: a query-selected workspace used
+ // to reach the plugin ungated because only the token-bound value was read.
+ var workspaceKey = PluginAdminWorkspaceResolver.Resolve(httpContext, workspaceScope.WorkspaceKey);
+
+ if (match.Route.Scope == HostAdminApiRouteScope.Workspace)
{
- var availability = await availabilityEvaluator
- .EvaluateAsync(match.Contributor.PluginId, workspaceKey, cancellationToken)
- .ConfigureAwait(false);
- if (!availability.IsAvailable)
+ if (string.IsNullOrWhiteSpace(workspaceKey))
+ {
+ return ApiProblems.BadRequest(
+ "A workspace is required. Platform operators select one with ?workspaceKey=.");
+ }
+
+ // A permitted caller still only reaches the plugin when it is effectively
+ // available in that workspace (REV2 §13): an entitlement lapse, missing
+ // capability, unhealthy runtime or inactive workspace returns 403 rather
+ // than routing into a plugin that should be dark. Ordered after the
+ // permission check so unavailability is never disclosed to callers who
+ // lack the permission.
+ if (httpContext.RequestServices.GetService() is { } availabilityEvaluator)
{
- // Generic response — no internal availability detail is leaked.
- return Results.Forbid();
+ var availability = await availabilityEvaluator
+ .EvaluateAsync(match.Contributor.PluginId, workspaceKey, cancellationToken)
+ .ConfigureAwait(false);
+ if (!availability.IsAvailable)
+ {
+ // Generic response — no internal availability detail is leaked.
+ return Results.Forbid();
+ }
}
}
@@ -98,8 +110,6 @@ private static async Task HandlePluginAdminRouteAsync(
HttpQueryValues.Read(httpContext.Request.Query),
await ReadJsonBodyAsync(httpContext, cancellationToken).ConfigureAwait(false),
ResolveUserId(httpContext.User),
- // Authoritative workspace scope from the caller's token: the caller's own workspace for a
- // workspace-scoped operator, null for a platform operator (super-admin/global).
workspaceKey);
var response = await match.Route.Handler.HandleAsync(request, cancellationToken).ConfigureAwait(false);
diff --git a/src/Administration/Api/PluginAdminWorkspaceResolver.cs b/src/Administration/Api/PluginAdminWorkspaceResolver.cs
new file mode 100644
index 00000000..cfd4121b
--- /dev/null
+++ b/src/Administration/Api/PluginAdminWorkspaceResolver.cs
@@ -0,0 +1,36 @@
+using Callora.Core.Application.Security;
+
+namespace Callora.Administration.Api;
+
+///
+/// Resolves the workspace a plugin Admin API request actually operates on (#109).
+///
+/// A workspace-bound session always resolves to its own workspace — a
+/// ?workspaceKey= value can never override it. A platform operator carries
+/// no binding and names the target explicitly; that query value is the effective
+/// workspace, and the host gates plugin availability against it exactly as it
+/// does for a bound one.
+///
+///
+internal static class PluginAdminWorkspaceResolver
+{
+ internal const string WorkspaceQueryKey = "workspaceKey";
+
+ public static string? Resolve(HttpContext httpContext, string? boundWorkspaceKey)
+ {
+ if (!string.IsNullOrWhiteSpace(boundWorkspaceKey))
+ {
+ return boundWorkspaceKey.Trim();
+ }
+
+ // Only a platform operator may select a workspace. Any other unbound
+ // principal is refused a workspace rather than inheriting the query value.
+ if (!WorkspaceScopeEvaluator.IsOperator(httpContext.User))
+ {
+ return null;
+ }
+
+ var requested = httpContext.Request.Query[WorkspaceQueryKey].ToString();
+ return string.IsNullOrWhiteSpace(requested) ? null : requested.Trim();
+ }
+}
diff --git a/src/Administration/Api/RbacEndpoints.cs b/src/Administration/Api/RbacEndpoints.cs
index 2020e2a4..d6824b73 100644
--- a/src/Administration/Api/RbacEndpoints.cs
+++ b/src/Administration/Api/RbacEndpoints.cs
@@ -60,21 +60,38 @@ public static void MapRbacEndpoints(this IEndpointRouteBuilder endpoints)
.WithName("Rbac_Permissions_List")
.RequirePermission(BackendPermissionKeys.RoleRead);
- group.MapPut("/roles/{role}", async (string role, RbacRoleUpsertApiRequest request, [FromServices] IBackendRbacStore store, CancellationToken cancellationToken) =>
+ group.MapPut("/roles/{role}", async (
+ string role,
+ RbacRoleUpsertApiRequest request,
+ [FromServices] IBackendRbacStore store,
+ [FromServices] IBackendUserStore userStore,
+ CancellationToken cancellationToken) =>
{
var permissions = request.Functions
.SelectMany(x => x.Actions.Select(action => $"{x.Function.Trim().ToLowerInvariant()}.{action.Trim().ToLowerInvariant()}"))
.ToArray();
await store.UpsertRoleAsync(role, permissions, cancellationToken).ConfigureAwait(false);
+ await RevokeSessionsOfRoleMembersAsync(role, store, userStore, cancellationToken).ConfigureAwait(false);
return Results.Ok(new RbacRoleApiResponse(role, permissions));
})
.WithName("Rbac_Roles_Upsert")
.RequirePermission(BackendPermissionKeys.RoleUpdate);
- group.MapDelete("/roles/{role}", async (string role, [FromServices] IBackendRbacStore store, CancellationToken cancellationToken) =>
+ group.MapDelete("/roles/{role}", async (
+ string role,
+ [FromServices] IBackendRbacStore store,
+ [FromServices] IBackendUserStore userStore,
+ CancellationToken cancellationToken) =>
{
+ // Collect the members first — after removal the assignments are gone.
+ var members = await ResolveRoleMembersAsync(role, store, cancellationToken).ConfigureAwait(false);
var removed = await store.RemoveRoleAsync(role, cancellationToken).ConfigureAwait(false);
+ if (removed)
+ {
+ await RevokeSessionsAsync(members, userStore, cancellationToken).ConfigureAwait(false);
+ }
+
return removed ? Results.NoContent() : Results.NotFound();
})
.WithName("Rbac_Roles_Delete")
@@ -101,20 +118,78 @@ public static void MapRbacEndpoints(this IEndpointRouteBuilder endpoints)
.WithName("Rbac_Users_List")
.RequirePermission(BackendPermissionKeys.RoleRead);
- group.MapPut("/users/{userId}", async (string userId, RbacUserUpsertApiRequest request, [FromServices] IBackendRbacStore store, CancellationToken cancellationToken) =>
+ group.MapPut("/users/{userId}", async (
+ string userId,
+ RbacUserUpsertApiRequest request,
+ [FromServices] IBackendRbacStore store,
+ [FromServices] IBackendUserStore userStore,
+ CancellationToken cancellationToken) =>
{
await store.UpsertUserRoleAsync(userId, request.Role, cancellationToken).ConfigureAwait(false);
+ // The user's authority just changed; sessions issued under the old role
+ // must not survive it (#105).
+ await userStore.RevokeSessionsAsync(userId, cancellationToken).ConfigureAwait(false);
return Results.Ok(new RbacUserApiResponse(userId, request.Role));
})
.WithName("Rbac_Users_Upsert")
.RequirePermission(BackendPermissionKeys.RoleUpdate);
- group.MapDelete("/users/{userId}", async (string userId, [FromServices] IBackendRbacStore store, CancellationToken cancellationToken) =>
+ group.MapDelete("/users/{userId}", async (
+ string userId,
+ [FromServices] IBackendRbacStore store,
+ [FromServices] IBackendUserStore userStore,
+ CancellationToken cancellationToken) =>
{
var removed = await store.RemoveUserRoleAsync(userId, cancellationToken).ConfigureAwait(false);
+ if (removed)
+ {
+ await userStore.RevokeSessionsAsync(userId, cancellationToken).ConfigureAwait(false);
+ }
+
return removed ? Results.NoContent() : Results.NotFound();
})
.WithName("Rbac_Users_Delete")
.RequirePermission(BackendPermissionKeys.RoleUpdate);
}
+
+ ///
+ /// External ids of the accounts currently assigned .
+ ///
+ private static async Task> ResolveRoleMembersAsync(
+ string role,
+ IBackendRbacStore store,
+ CancellationToken cancellationToken)
+ {
+ var assignments = await store.GetUserRolesAsync(cancellationToken).ConfigureAwait(false);
+ return assignments
+ .Where(x => string.Equals(x.Value, role, StringComparison.OrdinalIgnoreCase))
+ .Select(x => x.Key)
+ .ToArray();
+ }
+
+ ///
+ /// Changing a role's grants changes what its members may do. Their live sessions
+ /// carry the old permission claims, so they are revoked (#105) — fail-closed:
+ /// members re-authenticate and receive the new grants.
+ ///
+ private static async Task RevokeSessionsOfRoleMembersAsync(
+ string role,
+ IBackendRbacStore store,
+ IBackendUserStore userStore,
+ CancellationToken cancellationToken)
+ {
+ var members = await ResolveRoleMembersAsync(role, store, cancellationToken).ConfigureAwait(false);
+ await RevokeSessionsAsync(members, userStore, cancellationToken).ConfigureAwait(false);
+ }
+
+ private static async Task RevokeSessionsAsync(
+ IReadOnlyList userIds,
+ IBackendUserStore userStore,
+ CancellationToken cancellationToken)
+ {
+ foreach (var userId in userIds)
+ {
+ await userStore.RevokeSessionsAsync(userId, cancellationToken).ConfigureAwait(false);
+ }
+ }
}
diff --git a/src/Administration/Api/SetBackendUserActivationApiRequest.cs b/src/Administration/Api/SetBackendUserActivationApiRequest.cs
new file mode 100644
index 00000000..ced586c5
--- /dev/null
+++ b/src/Administration/Api/SetBackendUserActivationApiRequest.cs
@@ -0,0 +1,5 @@
+namespace Callora.Administration.Api;
+
+/// Enables or disables an account without deleting it (#104).
+/// True re-enables the account, false deactivates it.
+public sealed record SetBackendUserActivationApiRequest(bool IsActive);
diff --git a/src/Administration/Api/UserEndpoints.cs b/src/Administration/Api/UserEndpoints.cs
index 73bf639c..41ffa366 100644
--- a/src/Administration/Api/UserEndpoints.cs
+++ b/src/Administration/Api/UserEndpoints.cs
@@ -1,4 +1,5 @@
using Callora.Core.Api;
+using Callora.Core.Application.Audit;
using Callora.Core.Application.Events.Contracts;
using Callora.Core.Application.Security;
using Callora.Core.Application.Security.Events;
@@ -97,6 +98,14 @@ await businessEventBus.PublishSafelyAsync(
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) =>
{
+ // Mutates the global BackendUser — email, display name, password —
+ // which every workspace of that user shares. Platform operation
+ // (#102); workspace admins change membership, not identities.
+ if (!WorkspaceScopeEvaluator.IsOperator(httpContext.User))
+ {
+ return Results.Forbid();
+ }
+
if (!await CallerMayAccessAsync(httpContext, userStore, userId, cancellationToken).ConfigureAwait(false))
{
return Results.NotFound();
@@ -133,6 +142,15 @@ await businessEventBus.PublishSafelyAsync(
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) =>
{
+ // Erases the global account, every workspace membership and the
+ // global RBAC assignment. Platform operation (#102) — a workspace
+ // admin removing someone from its workspace uses the membership
+ // endpoint instead.
+ if (!WorkspaceScopeEvaluator.IsOperator(httpContext.User))
+ {
+ return Results.Forbid();
+ }
+
if (!await CallerMayAccessAsync(httpContext, userStore, userId, cancellationToken).ConfigureAwait(false))
{
return Results.NotFound();
@@ -154,6 +172,43 @@ await businessEventBus.PublishSafelyAsync(
}).WithName("Users_Delete")
.RequirePermission(BackendPermissionKeys.UserDelete);
+ // Deactivation is the non-destructive counterpart to DELETE (#104): the
+ // account keeps its data, memberships and audit trail but stops
+ // authenticating, and its live sessions are rejected at once (#105).
+ group.MapPut("/{userId}/activation", async (
+ string userId,
+ SetBackendUserActivationApiRequest request,
+ HttpContext httpContext,
+ IBackendUserStore userStore,
+ IHostAuditStore auditStore,
+ CancellationToken cancellationToken) =>
+ {
+ if (!WorkspaceScopeEvaluator.IsOperator(httpContext.User))
+ {
+ return Results.Forbid();
+ }
+
+ if (!await userStore.SetEnabledAsync(userId, request.IsActive, cancellationToken).ConfigureAwait(false))
+ {
+ return ApiProblems.NotFound($"User '{userId}' not found.");
+ }
+
+ await auditStore.AppendAsync(
+ new HostAuditEntry(
+ OccurredAtUtc: DateTimeOffset.UtcNow,
+ Action: request.IsActive ? "user.enable" : "user.disable",
+ PluginId: null,
+ IsSuccess: true,
+ RequestedBy: ResolveActor(httpContext.User),
+ Message: $"Account '{userId}' was {(request.IsActive ? "enabled" : "disabled")}."),
+ cancellationToken).ConfigureAwait(false);
+
+ var user = await userStore.GetByExternalIdAsync(userId, cancellationToken).ConfigureAwait(false);
+ return user is null ? Results.NotFound() : Results.Ok(ToResponse(user));
+ }).WithName("Users_SetActivation")
+ .Produces()
+ .RequirePermission(BackendPermissionKeys.UserUpdate);
+
group.MapGet("/{userId}/data-export", async (
string userId,
HttpContext httpContext,
@@ -161,6 +216,14 @@ await businessEventBus.PublishSafelyAsync(
IUserDataSubjectService dataSubjectService,
CancellationToken cancellationToken) =>
{
+ // The export discloses every workspace membership and the global
+ // RBAC role of the subject — data from workspaces the caller may
+ // not see. Platform operation (#102).
+ if (!WorkspaceScopeEvaluator.IsOperator(httpContext.User))
+ {
+ return Results.Forbid();
+ }
+
if (!await CallerMayAccessAsync(httpContext, userStore, userId, cancellationToken).ConfigureAwait(false))
{
return ApiProblems.NotFound($"User '{userId}' not found.");
@@ -179,6 +242,12 @@ await businessEventBus.PublishSafelyAsync(
/// Reads the caller's operator status and bound workspace from its claims.
/// Operators act platform-wide; everyone else is confined to a workspace.
///
+ /// Identity recorded in the audit trail for a security-state change.
+ private static string? ResolveActor(System.Security.Claims.ClaimsPrincipal user) =>
+ user.FindFirst("sub")?.Value
+ ?? user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
+ ?? user.Identity?.Name;
+
private static (bool IsOperator, string? WorkspaceKey) ResolveScope(HttpContext httpContext)
{
var isOperator = WorkspaceScopeEvaluator.IsOperator(httpContext.User);
@@ -216,6 +285,8 @@ private static BackendUserApiResponse ToResponse(BackendUser user)
DisplayName: user.DisplayName,
HasPassword: !string.IsNullOrWhiteSpace(user.PasswordHash),
PasswordHashAlgorithm: user.PasswordHashAlgorithm,
+ IsDisabled: user.IsDisabled,
+ IsLockedOut: user.LockoutEndsAtUtc is { } until && until > DateTimeOffset.UtcNow,
CreatedAtUtc: user.CreatedAtUtc,
UpdatedAtUtc: user.UpdatedAtUtc);
}
diff --git a/src/Administration/Api/WorkspaceEndpoints.cs b/src/Administration/Api/WorkspaceEndpoints.cs
index 1cd9e34a..9053c633 100644
--- a/src/Administration/Api/WorkspaceEndpoints.cs
+++ b/src/Administration/Api/WorkspaceEndpoints.cs
@@ -138,14 +138,24 @@ await businessEventBus.PublishSafelyAsync(
}).WithName("Workspaces_Delete")
.RequirePermission(BackendPermissionKeys.WorkspaceDelete);
+ // Workspace-membership administration (#102). A workspace-bound caller
+ // reaches only its own workspace; operators reach every workspace. This
+ // is the surface a workspace administrator uses instead of the global
+ // /api/users write endpoints.
group.MapGet("/{workspaceKey}/members", async (
string workspaceKey,
int? limit,
string? cursor,
+ HttpContext httpContext,
BackendHostOptions hostOptions,
IWorkspaceManagementStore workspaceStore,
CancellationToken cancellationToken) =>
{
+ if (!WorkspaceScopeEvaluator.HasWorkspaceAccess(httpContext.User, workspaceKey))
+ {
+ return ApiProblems.NotFound($"Workspace '{workspaceKey}' not found.");
+ }
+
if (string.IsNullOrWhiteSpace(hostOptions.DefaultTenantKey))
{
return ApiProblems.BadRequest("Workspace host default tenant key is not configured.");
@@ -167,18 +177,26 @@ await businessEventBus.PublishSafelyAsync(
ordered, limit, cursor, static x => x.UserId));
}).WithName("Workspaces_Members_List")
.Produces>()
- .RequirePermission(BackendPermissionKeys.WorkspaceRead);
+ .RequireAnyPermission(
+ BackendPermissionKeys.MembershipRead,
+ BackendPermissionKeys.WorkspaceRead);
group.MapPut("/{workspaceKey}/members/{userId}", async (
string workspaceKey,
string userId,
UpsertWorkspaceMemberApiRequest request,
+ HttpContext httpContext,
BackendHostOptions hostOptions,
IWorkspaceManagementStore workspaceStore,
IBusinessEventBus businessEventBus,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) =>
{
+ if (!WorkspaceScopeEvaluator.HasWorkspaceAccess(httpContext.User, workspaceKey))
+ {
+ return ApiProblems.NotFound($"Workspace '{workspaceKey}' not found.");
+ }
+
if (string.IsNullOrWhiteSpace(hostOptions.DefaultTenantKey))
{
return ApiProblems.BadRequest("Workspace host default tenant key is not configured.");
@@ -211,17 +229,25 @@ await businessEventBus.PublishSafelyAsync(
_ => Results.BadRequest()
};
}).WithName("Workspaces_Members_Upsert")
- .RequirePermission(BackendPermissionKeys.WorkspaceUpdate);
+ .RequireAnyPermission(
+ BackendPermissionKeys.MembershipUpdate,
+ BackendPermissionKeys.WorkspaceUpdate);
group.MapDelete("/{workspaceKey}/members/{userId}", async (
string workspaceKey,
string userId,
+ HttpContext httpContext,
BackendHostOptions hostOptions,
IWorkspaceManagementStore workspaceStore,
IBusinessEventBus businessEventBus,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) =>
{
+ if (!WorkspaceScopeEvaluator.HasWorkspaceAccess(httpContext.User, workspaceKey))
+ {
+ return ApiProblems.NotFound($"Workspace '{workspaceKey}' not found.");
+ }
+
if (string.IsNullOrWhiteSpace(hostOptions.DefaultTenantKey))
{
return ApiProblems.BadRequest("Workspace host default tenant key is not configured.");
@@ -252,7 +278,9 @@ await businessEventBus.PublishSafelyAsync(
_ => Results.BadRequest()
};
}).WithName("Workspaces_Members_Delete")
- .RequirePermission(BackendPermissionKeys.WorkspaceUpdate);
+ .RequireAnyPermission(
+ BackendPermissionKeys.MembershipDelete,
+ BackendPermissionKeys.WorkspaceUpdate);
}
diff --git a/src/Administration/PublicAPI.Unshipped.txt b/src/Administration/PublicAPI.Unshipped.txt
index 71cd9176..fd1f7481 100644
--- a/src/Administration/PublicAPI.Unshipped.txt
+++ b/src/Administration/PublicAPI.Unshipped.txt
@@ -23,10 +23,10 @@ Callora.Administration.Api.Admin.WorkspacePlugins.WorkspacePluginsController.Wor
Callora.Administration.Api.AdminContextEndpoints
Callora.Administration.Api.BackendUserApiResponse
Callora.Administration.Api.BackendUserApiResponse.$() -> Callora.Administration.Api.BackendUserApiResponse!
-Callora.Administration.Api.BackendUserApiResponse.BackendUserApiResponse(string! ExternalId, string? Email, string? DisplayName, bool HasPassword, string? PasswordHashAlgorithm, System.DateTimeOffset CreatedAtUtc, System.DateTimeOffset UpdatedAtUtc) -> void
+Callora.Administration.Api.BackendUserApiResponse.BackendUserApiResponse(string! ExternalId, string? Email, string? DisplayName, bool HasPassword, string? PasswordHashAlgorithm, bool IsDisabled, bool IsLockedOut, System.DateTimeOffset CreatedAtUtc, System.DateTimeOffset UpdatedAtUtc) -> void
Callora.Administration.Api.BackendUserApiResponse.CreatedAtUtc.get -> System.DateTimeOffset
Callora.Administration.Api.BackendUserApiResponse.CreatedAtUtc.init -> void
-Callora.Administration.Api.BackendUserApiResponse.Deconstruct(out string! ExternalId, out string? Email, out string? DisplayName, out bool HasPassword, out string? PasswordHashAlgorithm, out System.DateTimeOffset CreatedAtUtc, out System.DateTimeOffset UpdatedAtUtc) -> void
+Callora.Administration.Api.BackendUserApiResponse.Deconstruct(out string! ExternalId, out string? Email, out string? DisplayName, out bool HasPassword, out string? PasswordHashAlgorithm, out bool IsDisabled, out bool IsLockedOut, out System.DateTimeOffset CreatedAtUtc, out System.DateTimeOffset UpdatedAtUtc) -> void
Callora.Administration.Api.BackendUserApiResponse.DisplayName.get -> string?
Callora.Administration.Api.BackendUserApiResponse.DisplayName.init -> void
Callora.Administration.Api.BackendUserApiResponse.Email.get -> string?
@@ -36,6 +36,10 @@ Callora.Administration.Api.BackendUserApiResponse.ExternalId.get -> string!
Callora.Administration.Api.BackendUserApiResponse.ExternalId.init -> void
Callora.Administration.Api.BackendUserApiResponse.HasPassword.get -> bool
Callora.Administration.Api.BackendUserApiResponse.HasPassword.init -> void
+Callora.Administration.Api.BackendUserApiResponse.IsDisabled.get -> bool
+Callora.Administration.Api.BackendUserApiResponse.IsDisabled.init -> void
+Callora.Administration.Api.BackendUserApiResponse.IsLockedOut.get -> bool
+Callora.Administration.Api.BackendUserApiResponse.IsLockedOut.init -> void
Callora.Administration.Api.BackendUserApiResponse.PasswordHashAlgorithm.get -> string?
Callora.Administration.Api.BackendUserApiResponse.PasswordHashAlgorithm.init -> void
Callora.Administration.Api.BackendUserApiResponse.UpdatedAtUtc.get -> System.DateTimeOffset
@@ -401,6 +405,13 @@ Callora.Administration.Api.RbacUserUpsertApiRequest.Equals(Callora.Administratio
Callora.Administration.Api.RbacUserUpsertApiRequest.RbacUserUpsertApiRequest(string! Role) -> void
Callora.Administration.Api.RbacUserUpsertApiRequest.Role.get -> string!
Callora.Administration.Api.RbacUserUpsertApiRequest.Role.init -> void
+Callora.Administration.Api.SetBackendUserActivationApiRequest
+Callora.Administration.Api.SetBackendUserActivationApiRequest.$() -> Callora.Administration.Api.SetBackendUserActivationApiRequest!
+Callora.Administration.Api.SetBackendUserActivationApiRequest.Deconstruct(out bool IsActive) -> void
+Callora.Administration.Api.SetBackendUserActivationApiRequest.Equals(Callora.Administration.Api.SetBackendUserActivationApiRequest? other) -> bool
+Callora.Administration.Api.SetBackendUserActivationApiRequest.IsActive.get -> bool
+Callora.Administration.Api.SetBackendUserActivationApiRequest.IsActive.init -> void
+Callora.Administration.Api.SetBackendUserActivationApiRequest.SetBackendUserActivationApiRequest(bool IsActive) -> void
Callora.Administration.Api.SetEntitlementApiRequest
Callora.Administration.Api.SetEntitlementApiRequest.$() -> Callora.Administration.Api.SetEntitlementApiRequest!
Callora.Administration.Api.SetEntitlementApiRequest.Deconstruct(out string! PluginId, out string? WorkspaceKey, out string? TenantKey, out bool IsEntitled) -> void
@@ -943,6 +954,9 @@ override Callora.Administration.Api.RbacUserApiResponse.ToString() -> string!
override Callora.Administration.Api.RbacUserUpsertApiRequest.Equals(object? obj) -> bool
override Callora.Administration.Api.RbacUserUpsertApiRequest.GetHashCode() -> int
override Callora.Administration.Api.RbacUserUpsertApiRequest.ToString() -> string!
+override Callora.Administration.Api.SetBackendUserActivationApiRequest.Equals(object? obj) -> bool
+override Callora.Administration.Api.SetBackendUserActivationApiRequest.GetHashCode() -> int
+override Callora.Administration.Api.SetBackendUserActivationApiRequest.ToString() -> string!
override Callora.Administration.Api.SetEntitlementApiRequest.Equals(object? obj) -> bool
override Callora.Administration.Api.SetEntitlementApiRequest.GetHashCode() -> int
override Callora.Administration.Api.SetEntitlementApiRequest.ToString() -> string!
@@ -1099,6 +1113,8 @@ static Callora.Administration.Api.RbacUserApiResponse.operator !=(Callora.Admini
static Callora.Administration.Api.RbacUserApiResponse.operator ==(Callora.Administration.Api.RbacUserApiResponse? left, Callora.Administration.Api.RbacUserApiResponse? right) -> bool
static Callora.Administration.Api.RbacUserUpsertApiRequest.operator !=(Callora.Administration.Api.RbacUserUpsertApiRequest? left, Callora.Administration.Api.RbacUserUpsertApiRequest? right) -> bool
static Callora.Administration.Api.RbacUserUpsertApiRequest.operator ==(Callora.Administration.Api.RbacUserUpsertApiRequest? left, Callora.Administration.Api.RbacUserUpsertApiRequest? right) -> bool
+static Callora.Administration.Api.SetBackendUserActivationApiRequest.operator !=(Callora.Administration.Api.SetBackendUserActivationApiRequest? left, Callora.Administration.Api.SetBackendUserActivationApiRequest? right) -> bool
+static Callora.Administration.Api.SetBackendUserActivationApiRequest.operator ==(Callora.Administration.Api.SetBackendUserActivationApiRequest? left, Callora.Administration.Api.SetBackendUserActivationApiRequest? right) -> bool
static Callora.Administration.Api.SetEntitlementApiRequest.operator !=(Callora.Administration.Api.SetEntitlementApiRequest? left, Callora.Administration.Api.SetEntitlementApiRequest? right) -> bool
static Callora.Administration.Api.SetEntitlementApiRequest.operator ==(Callora.Administration.Api.SetEntitlementApiRequest? left, Callora.Administration.Api.SetEntitlementApiRequest? right) -> bool
static Callora.Administration.Api.SurfaceApiResponse.operator !=(Callora.Administration.Api.SurfaceApiResponse? left, Callora.Administration.Api.SurfaceApiResponse? right) -> bool
diff --git a/src/Core/Api/AuthEndpoints.cs b/src/Core/Api/AuthEndpoints.cs
index 6aa37140..0592f91f 100644
--- a/src/Core/Api/AuthEndpoints.cs
+++ b/src/Core/Api/AuthEndpoints.cs
@@ -39,10 +39,19 @@ public static void MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
.RequireSameOriginLogin()
.RequireRateLimiting(BackendRateLimiting.AuthPolicy);
- apiGroup.MapPost("/logout", (
+ // Logout is anonymous by design (an expired cookie must still be clearable),
+ // but it revokes server-side whatever valid session it was given (#105) —
+ // clearing the browser cookie alone would leave a copied token usable.
+ apiGroup.MapPost("/logout", async (
BackendHostOptions options,
- HttpContext httpContext) =>
+ HttpContext httpContext,
+ IBackendSessionRevocationStore revocationStore,
+ CancellationToken cancellationToken) =>
{
+ await BackendSessionRevocation
+ .RevokeCurrentSessionAsync(httpContext, revocationStore, cancellationToken)
+ .ConfigureAwait(false);
+
BackendAuthCookieService.ClearAuthCookie(
httpContext.Response,
options,
@@ -126,10 +135,22 @@ private static async Task HandleAdminLoginAsync(
return Results.Forbid();
}
+ // Operator sessions may be restricted to the external identity provider, so
+ // the second factor lives where one exists (#104). Workspace logins are
+ // unaffected.
+ if (options.RequireExternalIdentityForOperators &&
+ string.Equals(grant.Scope, BackendAuthScopes.Platform, StringComparison.Ordinal))
+ {
+ return Results.Forbid();
+ }
+
var roles = string.IsNullOrWhiteSpace(grant.Role) ? Array.Empty() : [grant.Role];
var customClaims = new Dictionary
{
- [BackendClaimTypes.CalloraScope] = grant.Scope
+ [BackendClaimTypes.CalloraScope] = grant.Scope,
+ // Binds the session to the account state it was issued under: rotating the
+ // stamp (password change, deactivation, RBAC change) revokes it (#105).
+ [BackendClaimTypes.SecurityStamp] = user.SecurityStamp
};
if (!string.IsNullOrWhiteSpace(grant.WorkspaceKey))
{
diff --git a/src/Core/Application/Plugins/Contracts/HostAdminApiRequest.cs b/src/Core/Application/Plugins/Contracts/HostAdminApiRequest.cs
index d314ea1f..6db5b25a 100644
--- a/src/Core/Application/Plugins/Contracts/HostAdminApiRequest.cs
+++ b/src/Core/Application/Plugins/Contracts/HostAdminApiRequest.cs
@@ -13,10 +13,13 @@ namespace Callora.Core.Application.Plugins.Contracts;
/// Parsed JSON body when provided.
/// Caller user identifier when available.
///
-/// The caller's bound workspace, resolved from the authenticated principal. Non-null for a
-/// workspace-scoped operator (who may only act within it); null for a platform operator
-/// (super-admin/global), who is not bound to a single workspace. Handlers of workspace-scoped
-/// resources must use this value as the authoritative scope, never a client-supplied workspace.
+/// The effective workspace, resolved by the host: the caller's bound workspace when the
+/// principal carries one — a client-supplied value can never override it — otherwise the
+/// workspace a platform operator selected explicitly via ?workspaceKey=. For a route
+/// declared this is non-null and the host has
+/// already confirmed the plugin is available there; only a
+/// route may see null. Handlers must use this
+/// value as the authoritative scope and never re-read a workspace from the query.
///
public sealed record HostAdminApiRequest(
string PluginId,
diff --git a/src/Core/Application/Plugins/Contracts/HostAdminApiRouteRegistration.cs b/src/Core/Application/Plugins/Contracts/HostAdminApiRouteRegistration.cs
index 5cd80de1..0f36328d 100644
--- a/src/Core/Application/Plugins/Contracts/HostAdminApiRouteRegistration.cs
+++ b/src/Core/Application/Plugins/Contracts/HostAdminApiRouteRegistration.cs
@@ -7,8 +7,14 @@ namespace Callora.Core.Application.Plugins.Contracts;
/// Route template relative to plugin root (for example: sip-accounts/{accountId}).
/// Permission key required for this route.
/// Handler instance for this route.
+///
+/// Whether the route is workspace-scoped (the default) or explicitly global. A
+/// workspace-scoped route only dispatches once the host resolved an effective
+/// workspace and confirmed the plugin is available there (#109).
+///
public sealed record HostAdminApiRouteRegistration(
string HttpMethod,
string RouteTemplate,
string RequiredPermission,
- IHostAdminApiRouteHandler Handler);
+ IHostAdminApiRouteHandler Handler,
+ HostAdminApiRouteScope Scope = HostAdminApiRouteScope.Workspace);
diff --git a/src/Core/Application/Plugins/Contracts/HostAdminApiRouteScope.cs b/src/Core/Application/Plugins/Contracts/HostAdminApiRouteScope.cs
new file mode 100644
index 00000000..d0ad8a2e
--- /dev/null
+++ b/src/Core/Application/Plugins/Contracts/HostAdminApiRouteScope.cs
@@ -0,0 +1,24 @@
+namespace Callora.Core.Application.Plugins.Contracts;
+
+///
+/// Whether a plugin Admin API route acts inside one workspace or across the
+/// platform. Governs the host's pre-dispatch gate (#109).
+///
+public enum HostAdminApiRouteScope
+{
+ ///
+ /// The route operates on workspace-scoped data — the default, and the safe
+ /// one. The host resolves the effective workspace (the caller's bound
+ /// workspace, or the one a platform operator names via
+ /// ?workspaceKey=), rejects the request when none is resolvable, and
+ /// dispatches only while the plugin is effectively available there.
+ ///
+ Workspace = 0,
+
+ ///
+ /// The route carries no workspace at all — plugin-wide status or metadata.
+ /// An explicit opt-out of the workspace gate: declare it only when the
+ /// handler genuinely reads nothing workspace-scoped.
+ ///
+ Global = 1
+}
diff --git a/src/Core/Application/Policies/BackendHostOptions.cs b/src/Core/Application/Policies/BackendHostOptions.cs
index cea9409d..dd8a58e9 100644
--- a/src/Core/Application/Policies/BackendHostOptions.cs
+++ b/src/Core/Application/Policies/BackendHostOptions.cs
@@ -68,16 +68,57 @@ public sealed class BackendHostOptions
public string? OidcAuthority { get; set; }
+ ///
+ /// Refuses the local password login for platform operators, so their
+ /// sessions can only come from (#104).
+ ///
+ /// Callora issues no second factor of its own. This is the enforceable
+ /// alternative: privileged operators authenticate at an identity provider that
+ /// does — MFA, conditional access, device trust — while workspace logins keep
+ /// working locally. Requires to be configured;
+ /// otherwise the host refuses to start rather than locking every operator out.
+ ///
+ ///
+ public bool RequireExternalIdentityForOperators { get; set; }
+
public string AuthCookieName { get; set; } = "callora_admin_auth";
public bool AuthCookieRequireHttps { get; set; }
+ ///
+ /// Enables the break-glass bootstrap credential: a key from
+ /// authenticates as platform super-admin. Set to
+ /// false once named integrations exist — a presented bootstrap key is
+ /// then rejected like any unknown credential.
+ ///
public bool EnableBootstrapApiKeys { get; set; } = true;
+ ///
+ /// Authentication policy switch: when true the host refuses to
+ /// start while bootstrap keys are enabled but none are configured.
+ ///
+ /// It never decides whether a presented credential is valid. An unknown key is
+ /// rejected with 401 in every permutation of this flag and
+ /// .
+ ///
+ ///
public bool RequireApiKeyAuthentication { get; set; } = true;
+ ///
+ /// Optional UTC instant after which bootstrap keys stop authenticating, even
+ /// while is true. Lets onboarding
+ /// hand out a credential that retires itself. null keeps bootstrap keys
+ /// valid until they are explicitly disabled or removed.
+ ///
+ public DateTimeOffset? BootstrapApiKeysExpireAtUtc { get; set; }
+
public string ApiKeyHeaderName { get; set; } = "X-Callora-Api-Key";
+ ///
+ /// Bootstrap credentials. Only meaningful while
+ /// is true; clearing the list
+ /// retires the break-glass path without a configuration-flag change.
+ ///
public string[] ApiKeys { get; set; } = [];
public BackendRbacRoleOptions[] RbacRoles { get; set; } = [];
diff --git a/src/Core/Application/Policies/BackendSecretHygiene.cs b/src/Core/Application/Policies/BackendSecretHygiene.cs
index 006c6a10..090b2dc4 100644
--- a/src/Core/Application/Policies/BackendSecretHygiene.cs
+++ b/src/Core/Application/Policies/BackendSecretHygiene.cs
@@ -16,8 +16,12 @@ public static class BackendSecretHygiene
/// Signing key shipped for local development; forges tokens if reused.
public const string DefaultJwtSigningKey = "callora-local-dev-signing-key-change-me";
- /// Password of the seeded demo administrator.
- public const string DefaultDemoAdminPassword = "admin123!";
+ ///
+ /// Password of the seeded demo administrator. Satisfies
+ /// BackendPasswordPolicy like every other local credential (#104) — a
+ /// seed that skipped the policy would be the one weak super-admin in the system.
+ ///
+ public const string DefaultDemoAdminPassword = "callora-demo-admin!";
/// Bootstrap API key shipped in the development configuration.
public const string DefaultApiKey = "callora-local-dev-key-change-me";
diff --git a/src/Core/Application/Security/BackendClaimTypes.cs b/src/Core/Application/Security/BackendClaimTypes.cs
index 4d5cac48..4bfa0e28 100644
--- a/src/Core/Application/Security/BackendClaimTypes.cs
+++ b/src/Core/Application/Security/BackendClaimTypes.cs
@@ -27,4 +27,18 @@ public static class BackendClaimTypes
/// platform-wide access.
///
public const string CalloraScope = "callora_scope";
+
+ ///
+ /// The account's security stamp at the moment the session was issued
+ /// (). A request whose stamp no longer
+ /// matches the stored one is rejected — that is how a password change,
+ /// deactivation or RBAC change revokes live sessions (#105).
+ ///
+ public const string SecurityStamp = "sst";
+
+ ///
+ /// Unique identifier of this session (JWT jti), so a single session can
+ /// be revoked on logout without touching the account's other sessions.
+ ///
+ public const string TokenId = "jti";
}
diff --git a/src/Core/Application/Security/BackendLockoutPolicy.cs b/src/Core/Application/Security/BackendLockoutPolicy.cs
new file mode 100644
index 00000000..1aed0515
--- /dev/null
+++ b/src/Core/Application/Security/BackendLockoutPolicy.cs
@@ -0,0 +1,16 @@
+namespace Callora.Core.Application.Security;
+
+///
+/// Bounded protection against credential guessing (#104). Consecutive failures on
+/// one account lock it for a fixed window; a success clears the counter. Deliberately
+/// account-scoped and time-bounded: it slows guessing without letting an attacker
+/// lock a known account out indefinitely. Per-IP throttling is the rate limiter's job.
+///
+public static class BackendLockoutPolicy
+{
+ /// Consecutive failures that trigger a lockout.
+ public const int MaxFailedAttempts = 10;
+
+ /// How long the account stays locked once the threshold is reached.
+ public static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
+}
diff --git a/src/Core/Application/Security/BackendPasswordPolicy.cs b/src/Core/Application/Security/BackendPasswordPolicy.cs
new file mode 100644
index 00000000..37af56e9
--- /dev/null
+++ b/src/Core/Application/Security/BackendPasswordPolicy.cs
@@ -0,0 +1,43 @@
+namespace Callora.Core.Application.Security;
+
+///
+/// The single password policy for every local credential (#104): the bootstrap
+/// operator seed, operator-created accounts and later credential changes all pass
+/// through here, so no path can set a weaker password than another.
+///
+public static class BackendPasswordPolicy
+{
+ /// Minimum length for any local password.
+ public const int MinimumLength = 12;
+
+ /// Upper bound, so a hashing call can never be turned into a DoS.
+ public const int MaximumLength = 256;
+
+ ///
+ /// Returns null when is acceptable, otherwise a
+ /// message describing the violated rule. The message names the rule, never the
+ /// supplied value.
+ ///
+ public static string? Validate(string? password)
+ {
+ if (string.IsNullOrWhiteSpace(password))
+ {
+ return "A password is required.";
+ }
+
+ if (password.Length < MinimumLength)
+ {
+ return $"The password must be at least {MinimumLength} characters long.";
+ }
+
+ if (password.Length > MaximumLength)
+ {
+ return $"The password must not exceed {MaximumLength} characters.";
+ }
+
+ return null;
+ }
+
+ /// Whether satisfies the policy.
+ public static bool IsAcceptable(string? password) => Validate(password) is null;
+}
diff --git a/src/Core/Application/Security/BackendPermissionKeys.cs b/src/Core/Application/Security/BackendPermissionKeys.cs
index 9370206b..1766a163 100644
--- a/src/Core/Application/Security/BackendPermissionKeys.cs
+++ b/src/Core/Application/Security/BackendPermissionKeys.cs
@@ -29,10 +29,24 @@ public static class BackendPermissionKeys
public const string ExtensionUpdate = "extension.update";
public const string RoleRead = "role.read";
public const string RoleUpdate = "role.update";
+ ///
+ /// Global identity administration (credentials, erasure, data-subject
+ /// export). Platform-only: workspace roles never receive a user.*
+ /// write permission, because these operations reach across workspaces.
+ ///
public const string UserCreate = "user.create";
public const string UserRead = "user.read";
public const string UserUpdate = "user.update";
public const string UserDelete = "user.delete";
+
+ ///
+ /// Workspace-membership administration: who belongs to a workspace and in
+ /// which workspace role. Grantable to workspace administrators — the
+ /// endpoints confine a workspace-bound caller to its own workspace.
+ ///
+ public const string MembershipRead = "membership.read";
+ public const string MembershipUpdate = "membership.update";
+ public const string MembershipDelete = "membership.delete";
public const string WorkspaceCreate = "workspace.create";
public const string WorkspaceRead = "workspace.read";
public const string WorkspaceUpdate = "workspace.update";
diff --git a/src/Core/Application/Security/BackendSecurityStamp.cs b/src/Core/Application/Security/BackendSecurityStamp.cs
new file mode 100644
index 00000000..b0590a98
--- /dev/null
+++ b/src/Core/Application/Security/BackendSecurityStamp.cs
@@ -0,0 +1,23 @@
+namespace Callora.Core.Application.Security;
+
+///
+/// The revocation handle of a local account (#105). Every issued session carries the
+/// stamp that was current at login; the request pipeline compares it against the
+/// stored one, so rotating the stamp invalidates every outstanding session of that
+/// account at once — password change, deactivation, deletion, RBAC change.
+///
+public static class BackendSecurityStamp
+{
+ /// A fresh, unguessable stamp.
+ public static string New() => Guid.NewGuid().ToString("N");
+
+ ///
+ /// Whether a session stamp still matches the account's. A stored stamp that is
+ /// empty (an account written before stamps existed) matches nothing, so those
+ /// sessions are treated as revoked rather than silently accepted.
+ ///
+ public static bool Matches(string? storedStamp, string? sessionStamp) =>
+ !string.IsNullOrWhiteSpace(storedStamp) &&
+ !string.IsNullOrWhiteSpace(sessionStamp) &&
+ string.Equals(storedStamp, sessionStamp, StringComparison.Ordinal);
+}
diff --git a/src/Core/Application/Security/IBackendSessionRevocationStore.cs b/src/Core/Application/Security/IBackendSessionRevocationStore.cs
new file mode 100644
index 00000000..caa276fe
--- /dev/null
+++ b/src/Core/Application/Security/IBackendSessionRevocationStore.cs
@@ -0,0 +1,23 @@
+namespace Callora.Core.Application.Security;
+
+///
+/// Records individually revoked sessions (#105). Logout revokes exactly the session
+/// that was used, leaving the account's other sessions alive; bulk revocation of an
+/// account runs through the security stamp instead.
+///
+/// Entries are bounded by the token lifetime: an entry may be dropped once the token
+/// it names has expired, because an expired token is rejected by signature validation
+/// anyway.
+///
+///
+public interface IBackendSessionRevocationStore
+{
+ /// Marks revoked until it expires. Idempotent.
+ Task RevokeAsync(string tokenId, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken = default);
+
+ /// Whether the session was revoked and its token has not expired yet.
+ Task IsRevokedAsync(string tokenId, CancellationToken cancellationToken = default);
+
+ /// Drops entries whose tokens have expired. Safe to call repeatedly.
+ Task PurgeExpiredAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Core/Application/Security/IBackendSessionValidator.cs b/src/Core/Application/Security/IBackendSessionValidator.cs
new file mode 100644
index 00000000..e0965f40
--- /dev/null
+++ b/src/Core/Application/Security/IBackendSessionValidator.cs
@@ -0,0 +1,18 @@
+using System.Security.Claims;
+
+namespace Callora.Core.Application.Security;
+
+///
+/// Decides whether an already-signature-valid session may still act (#105). Runs on
+/// the request hot path, so implementations must be bounded — a short-lived cache
+/// over the account lookup, never an unbounded per-request query fan-out.
+///
+public interface IBackendSessionValidator
+{
+ ///
+ /// Validates the principal behind a presented token. Returns null when the
+ /// session is still valid, otherwise a short reason for the rejection (logged,
+ /// never returned to the caller — the response stays a bare 401).
+ ///
+ Task ValidateAsync(ClaimsPrincipal principal, CancellationToken cancellationToken = default);
+}
diff --git a/src/Core/Application/Security/IBackendUserStore.cs b/src/Core/Application/Security/IBackendUserStore.cs
index 972f8cd8..d6ee6455 100644
--- a/src/Core/Application/Security/IBackendUserStore.cs
+++ b/src/Core/Application/Security/IBackendUserStore.cs
@@ -35,6 +35,11 @@ Task> ListByWorkspaceAsync(
string externalId,
CancellationToken cancellationToken = default);
+ ///
+ /// Creates or updates a local account. A supplied password must satisfy
+ /// and rotates the account's security stamp,
+ /// so every session issued before the change is revoked (#104, #105).
+ ///
Task UpsertCredentialsAsync(
string externalId,
string? email,
@@ -42,6 +47,25 @@ Task UpsertCredentialsAsync(
string? password,
CancellationToken cancellationToken = default);
+ ///
+ /// Enables or disables an account without deleting it (#104). Disabling rotates
+ /// the security stamp, so live sessions stop working immediately. Returns false
+ /// when the account does not exist.
+ ///
+ Task SetEnabledAsync(
+ string externalId,
+ bool enabled,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Rotates the account's security stamp, revoking every outstanding session
+ /// (#105). Used by authorization changes that do not touch credentials.
+ /// Returns false when the account does not exist.
+ ///
+ Task RevokeSessionsAsync(
+ string externalId,
+ CancellationToken cancellationToken = default);
+
Task RemoveAsync(
string externalId,
CancellationToken cancellationToken = default);
diff --git a/src/Core/Application/Security/SessionRevocationPurgeJobHandler.cs b/src/Core/Application/Security/SessionRevocationPurgeJobHandler.cs
new file mode 100644
index 00000000..a69e0eb4
--- /dev/null
+++ b/src/Core/Application/Security/SessionRevocationPurgeJobHandler.cs
@@ -0,0 +1,20 @@
+using Callora.Core.Application.Jobs.Contracts;
+using Callora.Core.Extensibility;
+
+namespace Callora.Core.Application.Security;
+
+///
+/// Drops revocation entries whose tokens have expired (#105). Without it the
+/// revocation list would grow with every logout forever, and the hot-path lookup
+/// with it.
+///
+[HostProtected]
+public sealed class SessionRevocationPurgeJobHandler(IBackendSessionRevocationStore store) : IBackgroundJobHandler
+{
+ public const string JobTypeName = "security.session-revocation-purge";
+
+ public string JobType => JobTypeName;
+
+ public Task ExecuteAsync(BackgroundJobExecutionContext context, CancellationToken cancellationToken = default) =>
+ store.PurgeExpiredAsync(cancellationToken);
+}
diff --git a/src/Core/Application/Security/SessionRevocationPurgeRecurringJobProvider.cs b/src/Core/Application/Security/SessionRevocationPurgeRecurringJobProvider.cs
new file mode 100644
index 00000000..762935b8
--- /dev/null
+++ b/src/Core/Application/Security/SessionRevocationPurgeRecurringJobProvider.cs
@@ -0,0 +1,18 @@
+using Callora.Core.Application.Jobs.Contracts;
+
+namespace Callora.Core.Application.Security;
+
+///
+/// Schedules the revocation-list purge. Hourly is enough: entries only become
+/// droppable once the token they name expires, and access tokens live an hour.
+///
+public sealed class SessionRevocationPurgeRecurringJobProvider : IRecurringJobProvider
+{
+ public IReadOnlyList GetDefinitions() =>
+ [
+ new RecurringJobDefinition(
+ SessionRevocationPurgeJobHandler.JobTypeName,
+ PayloadJson: "{}",
+ Interval: TimeSpan.FromHours(1))
+ ];
+}
diff --git a/src/Core/Application/Security/WorkspaceRolePermissions.cs b/src/Core/Application/Security/WorkspaceRolePermissions.cs
index 1e9bbf6e..62513c52 100644
--- a/src/Core/Application/Security/WorkspaceRolePermissions.cs
+++ b/src/Core/Application/Security/WorkspaceRolePermissions.cs
@@ -10,8 +10,16 @@ namespace Callora.Core.Application.Security;
/// (tenant.*, plugin.*, role.*, workspace.*, extension.*, config.update) —
/// those stay with . A workspace role
/// must never get "*", or it would satisfy RequirePermission on
-/// platform endpoints too. The user.* grants are safe only because the user
-/// endpoints are workspace-scoped (audit finding H1).
+/// platform endpoints too.
+///
+///
+/// DECISION (#102): workspace roles never receive user.* write
+/// permissions. Those operate on the global BackendUser — credentials,
+/// erasure, data-subject export — and therefore reach every workspace the
+/// victim belongs to. Workspace administration works on
+/// membership.* instead, which the endpoints confine to the caller's own
+/// workspace. user.read stays because the read endpoints are already
+/// filtered to the caller's workspace.
///
///
[CalloraInternal("Workspace-role permission grants — RBAC enforcement, not a plugin contract (REV2 §7.2)")]
@@ -31,9 +39,9 @@ public static class WorkspaceRolePermissions
BackendPermissionKeys.JobRead,
BackendPermissionKeys.ConfigRead,
BackendPermissionKeys.UserRead,
- BackendPermissionKeys.UserCreate,
- BackendPermissionKeys.UserUpdate,
- BackendPermissionKeys.UserDelete
+ BackendPermissionKeys.MembershipRead,
+ BackendPermissionKeys.MembershipUpdate,
+ BackendPermissionKeys.MembershipDelete
];
private static readonly IReadOnlyList MemberPermissions =
diff --git a/src/Core/Domain/Security/BackendRevokedSession.cs b/src/Core/Domain/Security/BackendRevokedSession.cs
new file mode 100644
index 00000000..95c7008a
--- /dev/null
+++ b/src/Core/Domain/Security/BackendRevokedSession.cs
@@ -0,0 +1,21 @@
+namespace Callora.Core.Domain.Security;
+
+///
+/// One revoked session, identified by the JWT jti it was issued with (#105).
+/// Rows survive a restart — otherwise a logged-out token would work again — and are
+/// purged once has passed.
+///
+public sealed class BackendRevokedSession
+{
+ /// The session's JWT identifier.
+ public string TokenId { get; set; } = string.Empty;
+
+ /// Owning account, for audit and bulk cleanup.
+ public string? Subject { get; set; }
+
+ /// When the underlying token expires; the row may be dropped afterwards.
+ public DateTimeOffset ExpiresAtUtc { get; set; }
+
+ /// When the revocation was recorded.
+ public DateTimeOffset RevokedAtUtc { get; set; }
+}
diff --git a/src/Core/Domain/Security/BackendUser.cs b/src/Core/Domain/Security/BackendUser.cs
index ec70281a..e5af3e41 100644
--- a/src/Core/Domain/Security/BackendUser.cs
+++ b/src/Core/Domain/Security/BackendUser.cs
@@ -16,6 +16,29 @@ public sealed class BackendUser
public string? DisplayName { get; set; }
+ ///
+ /// Opaque value stamped into every issued session. Rotating it revokes all of
+ /// this account's sessions at once — the mechanism behind password changes,
+ /// deactivation and authorization changes taking effect immediately (#105).
+ ///
+ public string SecurityStamp { get; set; } = string.Empty;
+
+ ///
+ /// A disabled account keeps its data, memberships and audit trail but
+ /// authenticates nowhere and has its live sessions rejected. The
+ /// non-destructive alternative to deletion (#104).
+ ///
+ public bool IsDisabled { get; set; }
+
+ /// Consecutive failed authentication attempts since the last success.
+ public int FailedAccessCount { get; set; }
+
+ ///
+ /// While set and in the future, authentication is refused regardless of the
+ /// supplied password — the bounded protection against credential guessing.
+ ///
+ public DateTimeOffset? LockoutEndsAtUtc { get; set; }
+
public DateTimeOffset CreatedAtUtc { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
diff --git a/src/Core/Infrastructure/Persistence/BackendPersistenceServiceCollectionExtensions.cs b/src/Core/Infrastructure/Persistence/BackendPersistenceServiceCollectionExtensions.cs
index 16b60479..2031a067 100644
--- a/src/Core/Infrastructure/Persistence/BackendPersistenceServiceCollectionExtensions.cs
+++ b/src/Core/Infrastructure/Persistence/BackendPersistenceServiceCollectionExtensions.cs
@@ -34,7 +34,16 @@ public static IServiceCollection AddBackendPersistence(
services.AddScoped();
services.AddScoped();
services.AddScoped();
- services.AddScoped();
+ // Session revocation (#105): a durable revocation list, the bounded
+ // account-state cache the request-path validator reads, and the decorator
+ // that drops a cached account the moment its stamp rotates.
+ services.AddScoped();
+ services.AddScoped(provider => new SessionStateInvalidatingUserStore(
+ provider.GetRequiredService(),
+ provider.GetRequiredService()));
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/Core/Infrastructure/Persistence/BackendRbacDatabaseSeeder.cs b/src/Core/Infrastructure/Persistence/BackendRbacDatabaseSeeder.cs
index 8e59d3ce..5ec90d5d 100644
--- a/src/Core/Infrastructure/Persistence/BackendRbacDatabaseSeeder.cs
+++ b/src/Core/Infrastructure/Persistence/BackendRbacDatabaseSeeder.cs
@@ -12,9 +12,9 @@ public sealed class BackendRbacDatabaseSeeder(
IPasswordHasher passwordHasher,
ILogger logger)
{
- // A bootstrap operator is a real super-admin; a trivially short password would
- // be a standing risk. Below this length the operator is refused, not weakened.
- private const int MinInitialOperatorPasswordLength = 12;
+ // Seeded accounts are real super-admins. They pass the same
+ // BackendPasswordPolicy as every later credential change (#104): below it the
+ // account is refused, never seeded with a weaker password.
public async Task SeedAsync(
HostPersistenceDbContext dbContext,
@@ -86,6 +86,14 @@ private async Task EnsureDemoAdminUserAsync(
return;
}
+ if (BackendPasswordPolicy.Validate(demoUser.Password) is { } violation)
+ {
+ logger.LogWarning(
+ "BackendHost.DemoAdminUser was not seeded: {Violation} Set a stronger BackendHost__DemoAdminUser__Password.",
+ violation);
+ return;
+ }
+
await UpsertOperatorAsync(
dbContext, demoUser.ExternalId, demoUser.Email, demoUser.DisplayName, demoUser.Password, superAdminRole, cancellationToken)
.ConfigureAwait(false);
@@ -112,13 +120,13 @@ private async Task EnsureInitialOperatorAsync(
logger.LogWarning(
"InitialOperator is enabled: bootstrap credentials are in configuration/.env. After first sign-in, change the password, set BackendHost__InitialOperator__Enabled=false, and remove the credentials from .env.");
- if (op.Password.Length < MinInitialOperatorPasswordLength)
+ if (BackendPasswordPolicy.Validate(op.Password) is { } violation)
{
// Fail closed: a too-weak bootstrap password yields no operator (loud
// warning) rather than a weak super-admin.
logger.LogWarning(
- "InitialOperator password is shorter than the required minimum of {MinLength} characters; no bootstrap operator was seeded. Set a stronger BackendHost__InitialOperator__Password.",
- MinInitialOperatorPasswordLength);
+ "InitialOperator was not seeded: {Violation} Set a stronger BackendHost__InitialOperator__Password.",
+ violation);
return;
}
@@ -162,6 +170,7 @@ private async Task UpsertOperatorAsync(
{
Id = Guid.NewGuid(),
ExternalId = externalId,
+ SecurityStamp = BackendSecurityStamp.New(),
CreatedAtUtc = nowUtc,
UpdatedAtUtc = nowUtc
};
@@ -176,6 +185,9 @@ private async Task UpsertOperatorAsync(
user.DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim();
user.PasswordHash = passwordHasher.HashPassword(user, password);
user.PasswordHashAlgorithm = "aspnet.identity.v3";
+ // Re-seeding rewrites the credential, so every session issued under the old
+ // one must die with it (#105).
+ user.SecurityStamp = BackendSecurityStamp.New();
var assignment = await dbContext.BackendRbacUserRoles
.SingleOrDefaultAsync(x => x.UserId == user.Id, cancellationToken)
diff --git a/src/Core/Infrastructure/Persistence/Configurations/BackendRevokedSessionEntityTypeConfiguration.cs b/src/Core/Infrastructure/Persistence/Configurations/BackendRevokedSessionEntityTypeConfiguration.cs
new file mode 100644
index 00000000..2976a3ff
--- /dev/null
+++ b/src/Core/Infrastructure/Persistence/Configurations/BackendRevokedSessionEntityTypeConfiguration.cs
@@ -0,0 +1,22 @@
+using Callora.Core.Domain.Security;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Callora.Core.Infrastructure.Persistence.Configurations;
+
+public sealed class BackendRevokedSessionEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("backend_revoked_sessions");
+ builder.HasKey(x => x.TokenId);
+
+ builder.Property(x => x.TokenId).HasMaxLength(128).IsRequired();
+ builder.Property(x => x.Subject).HasMaxLength(200);
+ builder.Property(x => x.ExpiresAtUtc).IsRequired();
+ builder.Property(x => x.RevokedAtUtc).IsRequired();
+
+ // The purge job and the hot-path lookup both filter on expiry.
+ builder.HasIndex(x => x.ExpiresAtUtc);
+ }
+}
diff --git a/src/Core/Infrastructure/Persistence/Configurations/BackendUserEntityTypeConfiguration.cs b/src/Core/Infrastructure/Persistence/Configurations/BackendUserEntityTypeConfiguration.cs
index edf7bf21..fd476c1f 100644
--- a/src/Core/Infrastructure/Persistence/Configurations/BackendUserEntityTypeConfiguration.cs
+++ b/src/Core/Infrastructure/Persistence/Configurations/BackendUserEntityTypeConfiguration.cs
@@ -18,6 +18,10 @@ public void Configure(EntityTypeBuilder builder)
builder.Property(x => x.PasswordHash).HasMaxLength(1024);
builder.Property(x => x.PasswordHashAlgorithm).HasMaxLength(100);
builder.Property(x => x.DisplayName).HasMaxLength(300);
+ builder.Property(x => x.SecurityStamp).HasMaxLength(64).IsRequired().HasDefaultValue(string.Empty);
+ builder.Property(x => x.IsDisabled).IsRequired().HasDefaultValue(false);
+ builder.Property(x => x.FailedAccessCount).IsRequired().HasDefaultValue(0);
+ builder.Property(x => x.LockoutEndsAtUtc);
builder.Property(x => x.CreatedAtUtc).IsRequired();
builder.Property(x => x.UpdatedAtUtc).IsRequired();
}
diff --git a/src/Core/Infrastructure/Persistence/EfBackendSessionRevocationStore.cs b/src/Core/Infrastructure/Persistence/EfBackendSessionRevocationStore.cs
new file mode 100644
index 00000000..dbe249a3
--- /dev/null
+++ b/src/Core/Infrastructure/Persistence/EfBackendSessionRevocationStore.cs
@@ -0,0 +1,69 @@
+using Callora.Core.Application.Security;
+using Callora.Core.Domain.Security;
+using Microsoft.EntityFrameworkCore;
+
+namespace Callora.Core.Infrastructure.Persistence;
+
+///
+/// Database-backed session revocation (#105). Durable on purpose: an in-memory list
+/// would resurrect every logged-out token on restart.
+///
+public sealed class EfBackendSessionRevocationStore(HostPersistenceDbContext dbContext)
+ : IBackendSessionRevocationStore
+{
+ public async Task RevokeAsync(
+ string tokenId,
+ DateTimeOffset expiresAtUtc,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tokenId);
+
+ var normalized = tokenId.Trim();
+ var existing = await dbContext.BackendRevokedSessions
+ .SingleOrDefaultAsync(x => x.TokenId == normalized, cancellationToken)
+ .ConfigureAwait(false);
+ if (existing is not null)
+ {
+ return;
+ }
+
+ dbContext.BackendRevokedSessions.Add(new BackendRevokedSession
+ {
+ TokenId = normalized,
+ ExpiresAtUtc = expiresAtUtc,
+ RevokedAtUtc = DateTimeOffset.UtcNow
+ });
+
+ try
+ {
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (DbUpdateException)
+ {
+ // Two logouts of the same session raced; the row exists either way.
+ dbContext.ChangeTracker.Clear();
+ }
+ }
+
+ public Task IsRevokedAsync(string tokenId, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(tokenId))
+ {
+ return Task.FromResult(false);
+ }
+
+ var normalized = tokenId.Trim();
+ var nowUtc = DateTimeOffset.UtcNow;
+ return dbContext.BackendRevokedSessions
+ .AsNoTracking()
+ .AnyAsync(x => x.TokenId == normalized && x.ExpiresAtUtc > nowUtc, cancellationToken);
+ }
+
+ public Task PurgeExpiredAsync(CancellationToken cancellationToken = default)
+ {
+ var nowUtc = DateTimeOffset.UtcNow;
+ return dbContext.BackendRevokedSessions
+ .Where(x => x.ExpiresAtUtc <= nowUtc)
+ .ExecuteDeleteAsync(cancellationToken);
+ }
+}
diff --git a/src/Core/Infrastructure/Persistence/EfBackendUserStore.cs b/src/Core/Infrastructure/Persistence/EfBackendUserStore.cs
index 2fc88d7a..c5c7ff0d 100644
--- a/src/Core/Infrastructure/Persistence/EfBackendUserStore.cs
+++ b/src/Core/Infrastructure/Persistence/EfBackendUserStore.cs
@@ -30,13 +30,115 @@ public sealed class EfBackendUserStore(
return null;
}
+ // Disabled and locked-out accounts fail before the hash is even verified,
+ // and produce the same null result as a wrong password — the caller must not
+ // be able to distinguish the cases (#104).
+ if (user.IsDisabled || IsLockedOut(user))
+ {
+ return null;
+ }
+
var verification = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
- return verification switch
+ if (verification == PasswordVerificationResult.Failed)
+ {
+ await RecordFailedAttemptAsync(user, cancellationToken).ConfigureAwait(false);
+ return null;
+ }
+
+ if (verification == PasswordVerificationResult.SuccessRehashNeeded)
+ {
+ user.PasswordHash = passwordHasher.HashPassword(user, password);
+ user.PasswordHashAlgorithm = "aspnet.identity.v3";
+ user.UpdatedAtUtc = DateTimeOffset.UtcNow;
+ }
+
+ await ClearFailedAttemptsAsync(user, cancellationToken).ConfigureAwait(false);
+ return user;
+ }
+
+ public async Task SetEnabledAsync(
+ string externalId,
+ bool enabled,
+ CancellationToken cancellationToken = default)
+ {
+ var user = await FindTrackedAsync(externalId, cancellationToken).ConfigureAwait(false);
+ if (user is null)
+ {
+ return false;
+ }
+
+ user.IsDisabled = !enabled;
+ if (enabled)
+ {
+ // Re-enabling clears the guessing counters, so a lockout accumulated
+ // before deactivation does not survive the reactivation.
+ user.FailedAccessCount = 0;
+ user.LockoutEndsAtUtc = null;
+ }
+
+ // Both directions revoke live sessions: disabling must stop them at once,
+ // and re-enabling should not resurrect pre-deactivation tokens.
+ user.SecurityStamp = BackendSecurityStamp.New();
+ user.UpdatedAtUtc = DateTimeOffset.UtcNow;
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+
+ public async Task RevokeSessionsAsync(
+ string externalId,
+ CancellationToken cancellationToken = default)
+ {
+ var user = await FindTrackedAsync(externalId, cancellationToken).ConfigureAwait(false);
+ if (user is null)
+ {
+ return false;
+ }
+
+ user.SecurityStamp = BackendSecurityStamp.New();
+ user.UpdatedAtUtc = DateTimeOffset.UtcNow;
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+
+ private static bool IsLockedOut(BackendUser user) =>
+ user.LockoutEndsAtUtc is { } until && until > DateTimeOffset.UtcNow;
+
+ private async Task RecordFailedAttemptAsync(BackendUser user, CancellationToken cancellationToken)
+ {
+ user.FailedAccessCount++;
+ if (user.FailedAccessCount >= BackendLockoutPolicy.MaxFailedAttempts)
+ {
+ user.LockoutEndsAtUtc = DateTimeOffset.UtcNow.Add(BackendLockoutPolicy.LockoutDuration);
+ user.FailedAccessCount = 0;
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task ClearFailedAttemptsAsync(BackendUser user, CancellationToken cancellationToken)
+ {
+ if (user.FailedAccessCount == 0 &&
+ user.LockoutEndsAtUtc is null &&
+ !dbContext.ChangeTracker.HasChanges())
+ {
+ return;
+ }
+
+ user.FailedAccessCount = 0;
+ user.LockoutEndsAtUtc = null;
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private Task