Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public static IServiceCollection AddApiModuleServices(
services.AddSingleton(module);
}

services.AddSingleton<ModuleEngineDiscoveryService>();

return services;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Validates webhook signatures for adapters.
/// </summary>
public interface IWebhookSignatureValidator
{
/// <summary>
/// Validates the signature for the provided request.
/// </summary>
bool Validate(ModuleEngineSecurity security, IReadOnlyDictionary<string, string> headers, string rawBody, string? providedSignature);
}
36 changes: 36 additions & 0 deletions src/Bravellian.Platform.Modularity.Api/WebhookAdapterRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Raw webhook request envelope understood by the transport adapters.
/// </summary>
/// <param name="Provider">Webhook provider identifier.</param>
/// <param name="EventType">Webhook event type.</param>
/// <param name="Headers">Raw headers supplied by the gateway.</param>
/// <param name="RawBody">Raw body text for signature validation.</param>
/// <param name="IdempotencyKey">Idempotency key supplied by provider.</param>
/// <param name="Attempt">Delivery attempt number.</param>
/// <param name="Signature">Optional supplied signature.</param>
/// <param name="Payload">Parsed payload DTO.</param>
public sealed record WebhookAdapterRequest<TPayload>(
string Provider,
string EventType,
IReadOnlyDictionary<string, string> Headers,
string RawBody,
string IdempotencyKey,
int Attempt,
string? Signature,
TPayload Payload);
22 changes: 22 additions & 0 deletions src/Bravellian.Platform.Modularity.Api/WebhookAdapterResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Adapter response that can be mapped to HTTP or queue responses.
/// </summary>
/// <param name="Outcome">Outcome the transport should emit.</param>
/// <param name="Reason">Optional reason for retries.</param>
public sealed record WebhookAdapterResponse(WebhookOutcomeType Outcome, string? Reason = null);
66 changes: 66 additions & 0 deletions src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Adapter that connects webhook engines to HTTP-style transports.
/// </summary>
public sealed class WebhookEngineAdapter
{
private readonly ModuleEngineDiscoveryService discovery;
private readonly IServiceProvider services;
private readonly IWebhookSignatureValidator signatureValidator;

/// <summary>
/// Initializes a new instance of the <see cref="WebhookEngineAdapter"/> class.
/// </summary>
public WebhookEngineAdapter(ModuleEngineDiscoveryService discovery, IServiceProvider services, IWebhookSignatureValidator signatureValidator)
{
this.discovery = discovery;
this.services = services;
this.signatureValidator = signatureValidator;
}

/// <summary>
/// Dispatches a webhook request to a registered engine.
/// </summary>
public async Task<WebhookAdapterResponse> DispatchAsync<TPayload>(WebhookAdapterRequest<TPayload> request, CancellationToken cancellationToken)
{
var descriptor = discovery.ResolveWebhookEngine(request.Provider, request.EventType)
?? throw new InvalidOperationException($"No webhook engine registered for provider '{request.Provider}' and event '{request.EventType}'.");

if (descriptor.Manifest.Security is { } security)
{
if (!signatureValidator.Validate(security, request.Headers, request.RawBody, request.Signature))
{
return new WebhookAdapterResponse(WebhookOutcomeType.Retry, "Signature validation failed");
}
Comment on lines +44 to +49

Copilot AI Dec 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 'if' statements can be combined.

Suggested change
if (descriptor.Manifest.Security is { } security)
{
if (!signatureValidator.Validate(security, request.Headers, request.RawBody, request.Signature))
{
return new WebhookAdapterResponse(WebhookOutcomeType.Retry, "Signature validation failed");
}
if (descriptor.Manifest.Security is { } security &&
!signatureValidator.Validate(security, request.Headers, request.RawBody, request.Signature))
{
return new WebhookAdapterResponse(WebhookOutcomeType.Retry, "Signature validation failed");

Copilot uses AI. Check for mistakes.
}

if (string.IsNullOrWhiteSpace(request.IdempotencyKey) && descriptor.Manifest.Security?.IdempotencyWindow is not null)
{
return new WebhookAdapterResponse(WebhookOutcomeType.Retry, "Missing idempotency key");
}
Comment on lines +44 to +55

Copilot AI Dec 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type WebhookAdapterResponse uses the outcome type to indicate what the transport should do, but returns "Retry" for both signature validation failures and missing idempotency keys. Security failures (like invalid signatures) should typically result in immediate rejection (4xx) rather than retry, as retrying won't fix an invalid signature. Consider returning a different outcome type (e.g., Reject or Acknowledge with failure reason) for security validation failures to distinguish them from transient failures that warrant retry.

Copilot uses AI. Check for mistakes.

var engine = discovery.ResolveEngine(descriptor, services) as IWebhookEngine<TPayload>
?? throw new InvalidOperationException($"Engine '{descriptor.Manifest.Id}' does not implement expected webhook contract.");

var outcome = await engine.HandleAsync(
new WebhookRequest<TPayload>(request.Provider, request.EventType, request.Payload, request.IdempotencyKey, request.Attempt),
cancellationToken).ConfigureAwait(false);

return new WebhookAdapterResponse(outcome.Outcome, outcome.Reason);

Copilot AI Dec 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Outcome from WebhookOutcome.Retry and WebhookOutcome.Enqueue should result in different handling, but the WebhookAdapterResponse only captures Outcome and Reason. The EnqueuedEvent field from WebhookOutcome is being silently dropped. If the transport layer needs to enqueue events, it won't have access to the event payload. Consider adding an EnqueuedEvent property to WebhookAdapterResponse and mapping outcome.EnqueuedEvent to it.

Suggested change
return new WebhookAdapterResponse(outcome.Outcome, outcome.Reason);
return new WebhookAdapterResponse(outcome.Outcome, outcome.Reason, outcome.EnqueuedEvent);

Copilot uses AI. Check for mistakes.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public static IServiceCollection AddBackgroundModuleServices(
ILoggerFactory? loggerFactory = null)
{
ModuleRegistry.InitializeModules<IBackgroundModule>(ModuleCategory.Background, configuration, services, loggerFactory);
services.AddSingleton<ModuleEngineDiscoveryService>();
return services;
}
}
31 changes: 31 additions & 0 deletions src/Bravellian.Platform.Modularity.Core/EngineKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Supported engine types. Engines are framework-agnostic and must not depend on transport concerns.
/// </summary>
public enum EngineKind
{
/// <summary>
/// UI-first engines that produce view models and navigation outcomes.
/// </summary>
Ui,

/// <summary>
/// Webhook engines that react to external callbacks.
/// </summary>
Webhook,
}
26 changes: 26 additions & 0 deletions src/Bravellian.Platform.Modularity.Core/IEngineModule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Marker interface for modules that expose engines.
/// </summary>
public interface IEngineModule
{
/// <summary>
/// Provides engine descriptors for the module.
/// </summary>
IEnumerable<ModuleEngineDescriptor> DescribeEngines();
}
31 changes: 31 additions & 0 deletions src/Bravellian.Platform.Modularity.Core/IUiEngine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Generic UI engine contract that operates on DTOs and produces view models.
/// </summary>
/// <typeparam name="TInput">Input DTO.</typeparam>
/// <typeparam name="TViewModel">View model output.</typeparam>
public interface IUiEngine<TInput, TViewModel>
{
/// <summary>
/// Executes the engine using the provided command DTO.
/// </summary>
/// <param name="command">Command DTO.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A view model and any navigation tokens emitted.</returns>
Task<UiEngineResult<TViewModel>> ExecuteAsync(TInput command, CancellationToken cancellationToken);
Comment on lines +25 to +30

Copilot AI Dec 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter name 'command' in ExecuteAsync is misleading when the TInput type parameter could represent any input DTO, not just commands. For example, in a read-only UI engine, the input might be a query DTO. Consider renaming to 'input' to match the generic type parameter TInput, which would make the API more semantically accurate and avoid confusion about CQRS command patterns.

Suggested change
/// Executes the engine using the provided command DTO.
/// </summary>
/// <param name="command">Command DTO.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A view model and any navigation tokens emitted.</returns>
Task<UiEngineResult<TViewModel>> ExecuteAsync(TInput command, CancellationToken cancellationToken);
/// Executes the engine using the provided input DTO.
/// </summary>
/// <param name="input">Input DTO.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A view model and any navigation tokens emitted.</returns>
Task<UiEngineResult<TViewModel>> ExecuteAsync(TInput input, CancellationToken cancellationToken);

Copilot uses AI. Check for mistakes.
}
27 changes: 27 additions & 0 deletions src/Bravellian.Platform.Modularity.Core/IWebhookEngine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Webhook engine contract. Engines decide the outcome without transport coupling.
/// </summary>
/// <typeparam name="TPayload">Webhook payload type.</typeparam>
public interface IWebhookEngine<TPayload>
{
/// <summary>
/// Handles a webhook request.
/// </summary>
Task<WebhookOutcome> HandleAsync(WebhookRequest<TPayload> request, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Adapter-level hints used by hosts to wire up engines to transports.
/// </summary>
/// <param name="RequiresRawRequestBody">True if the adapter must expose the raw request body to the engine.</param>
/// <param name="RequiresRawHeaders">True if the adapter must expose raw headers.</param>
/// <param name="SupportsChallengeResponses">True if the adapter should support verification/challenge responses.</param>
/// <param name="RequiresAuthenticatedUser">True if the adapter must enforce authentication.</param>
/// <param name="RequiresTenantContext">True if the adapter must enforce tenancy.</param>
public sealed record ModuleEngineAdapterHints(
bool RequiresRawRequestBody = false,
bool RequiresRawHeaders = false,
bool SupportsChallengeResponses = false,
bool RequiresAuthenticatedUser = false,
bool RequiresTenantContext = false);
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Declares the actions and events an engine can process.
/// </summary>
/// <param name="Actions">Named commands/actions the engine supports.</param>
/// <param name="Events">Events emitted by the engine.</param>
/// <param name="SupportsAsync">Indicates async execution is supported.</param>
/// <param name="SupportsStreaming">Indicates streaming updates are supported.</param>
public sealed record ModuleEngineCapabilities(
IReadOnlyCollection<string> Actions,
IReadOnlyCollection<string> Events,
bool SupportsAsync = true,
bool SupportsStreaming = false);
Comment on lines +23 to +28

Copilot AI Dec 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SupportsStreaming parameter is included in the ModuleEngineCapabilities record but there is no corresponding infrastructure in the engine contracts (IUiEngine, IWebhookEngine) to support streaming. If streaming is planned for future versions, add documentation explaining that this is a reserved capability flag. If not, consider removing it to avoid confusion about whether engines can actually stream responses.

Copilot uses AI. Check for mistakes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Compatibility metadata for engine evolution.
/// </summary>
/// <param name="MinHostVersion">Minimum host version required.</param>
/// <param name="BreakingChanges">Human-readable notes for breaking changes.</param>
public sealed record ModuleEngineCompatibility(string? MinHostVersion, string? BreakingChanges);
Loading
Loading