Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,6 @@ src/Surface.Rendering/wwwroot/surface-app/

custom/plugins/*/node_modules/
custom/static-plugins/*/node_modules/
# Archived plugins keep their own app trees; their dependencies are never tracked.
custom/static-plugins/_archive/*/node_modules/
custom/static-plugins/*/app/*/node_modules/
3 changes: 3 additions & 0 deletions custom/static-plugins/Communication/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
"communication.video",
"communication.webrtc"
],
"sensitiveFields": [
"remoteParty"
],
"dependencies": {
"Callora.Core": ">=0.1.0-local",
"Callora.Plugin.Communication.Abstractions": ">=0.1.0-local"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,12 @@ public sealed record AudioFormat(AudioCodec Codec, int SampleRateHz, int FrameMi
{
/// <summary>SIP/PSTN-Standard: G.711 µ-law, 8 kHz, 20-ms-Frames.</summary>
public static AudioFormat G711Ulaw8k20ms { get; } = new(AudioCodec.G711Ulaw, 8000, 20);

/// <summary>
/// Exakte Frame-Größe in Bytes. G.711 kodiert ein Sample je Byte, also
/// <c>SampleRateHz × FrameMilliseconds / 1000</c> — für 8 kHz/20 ms sind das 160 Bytes.
/// Das ausgehandelte Format wird damit prüfbar: eingehende Frames dürfen genau
/// diese Größe haben, statt beliebig groß zu sein (#108).
/// </summary>
public int BytesPerFrame => SampleRateHz * FrameMilliseconds / 1000;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Net.WebSockets;
using System.Text;
using Callora.Plugin.Communication.Application.Streaming;
using Callora.Plugin.Communication.Infrastructure.Transport;

namespace Callora.Plugin.Communication.Api.WebSocket;

Expand Down Expand Up @@ -38,28 +40,17 @@ public async ValueTask SendAsync(WebRtcSignalMessage message, CancellationToken

/// <summary>
/// Reads the next whole text message and returns its raw JSON, or <see langword="null"/> when the
/// socket closes. Fragmented frames are reassembled; parsing/validation is the caller's concern so a
/// single malformed frame can be logged and ignored without ending the stream.
/// socket closes. Fragmented frames are reassembled under a hard byte cap and an idle timeout
/// (#108); parsing/validation is the caller's concern so a single malformed frame can be logged
/// and ignored without ending the stream.
/// </summary>
public async ValueTask<string?> ReceiveTextAsync(CancellationToken cancellationToken = default)
{
using var message = new MemoryStream();

WebSocketReceiveResult result;
do
{
result = await socket.ReceiveAsync(_receiveBuffer, cancellationToken).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
return null;
}

message.Write(_receiveBuffer, 0, result.Count);
}
while (!result.EndOfMessage);

return Encoding.UTF8.GetString(message.GetBuffer(), 0, (int)message.Length);
}
public ValueTask<string?> ReceiveTextAsync(CancellationToken cancellationToken = default) =>
BoundedWebSocketReader.ReadTextAsync(
socket,
_receiveBuffer,
CommunicationStreamLimits.MaxSignalingMessageBytes,
CommunicationStreamLimits.IdleTimeout,
cancellationToken);

/// <inheritdoc />
public void Dispose() => _sendLock.Dispose();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
namespace Callora.Plugin.Communication.Application.Admin.Calls;

/// <summary>
/// Resolves the workspace a call-control admin request operates on. The caller's token-bound workspace
/// (<see cref="HostAdminApiRequest.WorkspaceKey"/>, set authoritatively by the host) always wins, so a
/// workspace-scoped operator can never reach another workspace. A platform operator (no bound
/// workspace) must name the target explicitly via <c>?workspaceKey=</c>; absent that the request is
/// rejected rather than defaulting to something dangerous. Mirrors the SIP-account scope helper.
/// Reads the workspace a call-control admin request operates on. The host resolves it
/// authoritatively into <see cref="HostAdminApiRequest.WorkspaceKey"/> — the caller's bound
/// workspace when it has one, otherwise the workspace a platform operator named explicitly —
/// and has already confirmed the plugin is available there (#109). The plugin therefore
/// never reads a workspace from the query itself; that would bypass the host's gate.
/// Mirrors the SIP-account scope helper.
/// </summary>
internal static class CallAdminScope
{
Expand All @@ -16,22 +17,16 @@ public static bool TryResolve(
out string workspaceKey,
out HostAdminApiResponse? error)
{
var resolved = request.WorkspaceKey;
if (string.IsNullOrWhiteSpace(resolved) &&
request.Query.TryGetValue("workspaceKey", out var values) &&
values.Length > 0)
{
resolved = values[0];
}
ArgumentNullException.ThrowIfNull(request);

if (string.IsNullOrWhiteSpace(resolved))
if (string.IsNullOrWhiteSpace(request.WorkspaceKey))
{
workspaceKey = string.Empty;
error = new HostAdminApiResponse(400, new { error = "A workspace is required." });
return false;
}

workspaceKey = resolved.Trim();
workspaceKey = request.WorkspaceKey.Trim();
error = null;
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,15 @@ public CommunicationAdminApiExtensionContributor(IReadOnlyList<HostAdminApiRoute

_routes =
[
// Plugin-wide health, deliberately not workspace-scoped: it reports
// whether Communication itself is usable, which an operator must be
// able to read even while no workspace is entitled (#109).
new HostAdminApiRouteRegistration(
"GET",
"status",
CommunicationPermissionKeys.AccountsRead,
new CommunicationStatusRouteHandler()),
new CommunicationStatusRouteHandler(),
HostAdminApiRouteScope.Global),
.. accountRoutes,
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Callora.Core.Application.Plugins.Contracts;
using Callora.Core.Application.Secrets.Contracts;
using Callora.Plugin.Communication.Application.Accounts;
using Callora.Plugin.Communication.Application.Voice;
using Callora.Plugin.Communication.Domain.Accounts;

namespace Callora.Plugin.Communication.Application.Admin.SipAccounts;
Expand All @@ -15,12 +16,16 @@ namespace Callora.Plugin.Communication.Application.Admin.SipAccounts;
public sealed class CreateSipAccountRouteHandler(
ISipAccountStore store,
IPluginDataProtector dataProtector,
string pluginId) : IHostAdminApiRouteHandler
string pluginId,
ISipAccountRuntimeReconciler? reconciler = null,
TimeProvider? timeProvider = null) : IHostAdminApiRouteHandler
{
private static readonly JsonSerializerOptions SerializerOptions =
new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } };

private readonly SipAccountConnectionFactory _connectionFactory = new(dataProtector, pluginId);
private readonly SipAccountRuntimeCoordinator _runtime =
new(store, reconciler, timeProvider ?? TimeProvider.System);

/// <inheritdoc />
public async ValueTask<HostAdminApiResponse> HandleAsync(
Expand Down Expand Up @@ -53,6 +58,13 @@ public async ValueTask<HostAdminApiResponse> HandleAsync(
return Bad("displayName is required.");
}

// Refuse an authentication method the provider cannot connect before anything is
// persisted (#111) — accepting it would create an account that is silently skipped.
if (SipAuthMethodValidation.Reject(body.AuthMethod) is { } unsupported)
{
return unsupported;
}

if (!_connectionFactory.TryBuild(body, existing: null, out var connection, out var error))
{
return Bad(error!);
Expand All @@ -73,7 +85,10 @@ public async ValueTask<HostAdminApiResponse> HandleAsync(
body.Enabled ?? true);

await store.AddAsync(account, cancellationToken).ConfigureAwait(false);
return new HostAdminApiResponse(201, SipAccountResponse.FromDomain(account));

// A created-and-enabled account must register now, not at the next restart (#110).
var runtimeFailure = await _runtime.ReconcileAsync(account, cancellationToken).ConfigureAwait(false);
return runtimeFailure ?? new HostAdminApiResponse(201, SipAccountResponse.FromDomain(account));
}

private static HostAdminApiResponse Bad(string message) => new(400, new { error = message });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
using Callora.Core.Application.Plugins.Contracts;
using Callora.Plugin.Communication.Application.Accounts;
using Callora.Plugin.Communication.Application.Voice;

namespace Callora.Plugin.Communication.Application.Admin.SipAccounts;

/// <summary>Handles <c>DELETE sip-accounts/{accountId}</c> — removes an account of the caller's workspace.</summary>
public sealed class DeleteSipAccountRouteHandler(ISipAccountStore store) : IHostAdminApiRouteHandler
public sealed class DeleteSipAccountRouteHandler(
ISipAccountStore store,
ISipAccountRuntimeReconciler? reconciler = null) : IHostAdminApiRouteHandler
{
private readonly SipAccountRuntimeCoordinator _runtime =
new(store, reconciler, TimeProvider.System);

/// <inheritdoc />
public async ValueTask<HostAdminApiResponse> HandleAsync(
HostAdminApiRequest request,
Expand All @@ -19,6 +25,12 @@ public async ValueTask<HostAdminApiResponse> HandleAsync(

var accountId = request.RouteValues.TryGetValue("accountId", out var value) ? value : string.Empty;
var deleted = await store.DeleteAsync(workspaceKey, accountId, cancellationToken).ConfigureAwait(false);
if (deleted)
{
// Deregister before returning: a deleted account must not keep a live
// registration until the next restart (#110).
await _runtime.RemoveAsync(workspaceKey, accountId, cancellationToken).ConfigureAwait(false);
}

return deleted
? new HostAdminApiResponse(204)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Callora.Core.Application.Plugins.Contracts;
using Callora.Plugin.Communication.Application.Accounts;
using Callora.Plugin.Communication.Application.Voice;

namespace Callora.Plugin.Communication.Application.Admin.SipAccounts;

Expand All @@ -8,8 +9,15 @@ namespace Callora.Plugin.Communication.Application.Admin.SipAccounts;
/// is provisioned. One handler class, registered once per target state, so enable and disable share the
/// same guarded lookup and persistence.
/// </summary>
public sealed class SetSipAccountEnabledRouteHandler(ISipAccountStore store, bool enabled) : IHostAdminApiRouteHandler
public sealed class SetSipAccountEnabledRouteHandler(
ISipAccountStore store,
bool enabled,
ISipAccountRuntimeReconciler? reconciler = null,
TimeProvider? timeProvider = null) : IHostAdminApiRouteHandler
{
private readonly SipAccountRuntimeCoordinator _runtime =
new(store, reconciler, timeProvider ?? TimeProvider.System);

/// <inheritdoc />
public async ValueTask<HostAdminApiResponse> HandleAsync(
HostAdminApiRequest request,
Expand Down Expand Up @@ -38,6 +46,10 @@ public async ValueTask<HostAdminApiResponse> HandleAsync(
}

await store.UpdateAsync(account, cancellationToken).ConfigureAwait(false);
return new HostAdminApiResponse(200, SipAccountResponse.FromDomain(account));

// Enabling registers now; disabling deregisters now, so a disabled account stops
// taking calls immediately rather than at the next restart (#110).
var runtimeFailure = await _runtime.ReconcileAsync(account, cancellationToken).ConfigureAwait(false);
return runtimeFailure ?? new HostAdminApiResponse(200, SipAccountResponse.FromDomain(account));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Callora.Core.Application.Plugins.Contracts;
using Callora.Core.Application.Secrets.Contracts;
using Callora.Plugin.Communication.Application.Accounts;
using Callora.Plugin.Communication.Application.Voice;

namespace Callora.Plugin.Communication.Application.Admin.SipAccounts;

Expand All @@ -13,10 +14,16 @@ namespace Callora.Plugin.Communication.Application.Admin.SipAccounts;
public static class SipAccountAdminRoutes
{
/// <summary>Creates the route registrations bound to the given store, data protector and plugin id.</summary>
/// <param name="reconciler">
/// Brings the live runtime in line after every successful mutation (#110). Null in a
/// deployment that operates no voice runtime, in which case the routes are pure
/// persistence — which is then the truthful behaviour.
/// </param>
public static IReadOnlyList<HostAdminApiRouteRegistration> Build(
ISipAccountStore store,
IPluginDataProtector dataProtector,
string pluginId)
string pluginId,
ISipAccountRuntimeReconciler? reconciler = null)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(dataProtector);
Expand All @@ -32,19 +39,19 @@ public static IReadOnlyList<HostAdminApiRouteRegistration> Build(
new GetSipAccountRouteHandler(store)),
new HostAdminApiRouteRegistration(
"POST", "sip-accounts", CommunicationPermissionKeys.AccountsManage,
new CreateSipAccountRouteHandler(store, dataProtector, pluginId)),
new CreateSipAccountRouteHandler(store, dataProtector, pluginId, reconciler)),
new HostAdminApiRouteRegistration(
"PUT", "sip-accounts/{accountId}", CommunicationPermissionKeys.AccountsManage,
new UpdateSipAccountRouteHandler(store, dataProtector, pluginId)),
new UpdateSipAccountRouteHandler(store, dataProtector, pluginId, reconciler)),
new HostAdminApiRouteRegistration(
"POST", "sip-accounts/{accountId}/enable", CommunicationPermissionKeys.AccountsManage,
new SetSipAccountEnabledRouteHandler(store, enabled: true)),
new SetSipAccountEnabledRouteHandler(store, enabled: true, reconciler)),
new HostAdminApiRouteRegistration(
"POST", "sip-accounts/{accountId}/disable", CommunicationPermissionKeys.AccountsManage,
new SetSipAccountEnabledRouteHandler(store, enabled: false)),
new SetSipAccountEnabledRouteHandler(store, enabled: false, reconciler)),
new HostAdminApiRouteRegistration(
"DELETE", "sip-accounts/{accountId}", CommunicationPermissionKeys.AccountsManage,
new DeleteSipAccountRouteHandler(store)),
new DeleteSipAccountRouteHandler(store, reconciler)),
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
namespace Callora.Plugin.Communication.Application.Admin.SipAccounts;

/// <summary>
/// Resolves the workspace a SIP-account admin request operates on. The caller's token-bound workspace
/// (<see cref="HostAdminApiRequest.WorkspaceKey"/>, set authoritatively by the host) always wins, so a
/// workspace-scoped operator can never reach another workspace. A platform operator (no bound
/// workspace) must name the target explicitly via <c>?workspaceKey=</c>; absent that, the request is
/// rejected rather than defaulting to something dangerous.
/// Reads the workspace a SIP-account admin request operates on. The host resolves it
/// authoritatively into <see cref="HostAdminApiRequest.WorkspaceKey"/>the caller's bound
/// workspace when it has one, otherwise the workspace a platform operator named explicitly —
/// and has already confirmed the plugin is available there (#109). The plugin therefore
/// never reads a workspace from the query itself; that would bypass the host's gate.
/// </summary>
internal static class SipAccountAdminScope
{
Expand All @@ -16,22 +16,16 @@ public static bool TryResolve(
out string workspaceKey,
out HostAdminApiResponse? error)
{
var resolved = request.WorkspaceKey;
if (string.IsNullOrWhiteSpace(resolved) &&
request.Query.TryGetValue("workspaceKey", out var values) &&
values.Length > 0)
{
resolved = values[0];
}
ArgumentNullException.ThrowIfNull(request);

if (string.IsNullOrWhiteSpace(resolved))
if (string.IsNullOrWhiteSpace(request.WorkspaceKey))
{
workspaceKey = string.Empty;
error = new HostAdminApiResponse(400, new { error = "A workspace is required." });
return false;
}

workspaceKey = resolved.Trim();
workspaceKey = request.WorkspaceKey.Trim();
error = null;
return true;
}
Expand Down
Loading
Loading