Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
271bf69
Initial plan
Copilot Aug 2, 2025
9c1f402
Implement Dapr Pub/Sub event publishing with Azure Service Bus support
Copilot Aug 2, 2025
5ed2482
Fix DaprClient dependency injection registration
Copilot Aug 3, 2025
ca04e53
Small change for test
slombardo Aug 3, 2025
2712740
Fix AddDapr() compilation error by chaining to MVC builder
Copilot Aug 3, 2025
f037dfb
Add security documentation and basic validation to Dapr endpoints
Copilot Aug 3, 2025
df070f8
Secure Dapr endpoints with localhost-only access and enhanced validation
Copilot Aug 3, 2025
e548f97
Remove InProcessEventPublisher and unify eventing with Dapr
Copilot Aug 3, 2025
af5d100
Update FrontDesk.Api/Endpoints/DaprEventEndpoints.cs
slombardo Aug 3, 2025
455ce97
Update FrontDesk.Api/Endpoints/DaprEventEndpoints.cs
slombardo Aug 3, 2025
8befeb4
Update docs/eventing-dapr-setup.md
slombardo Aug 3, 2025
992cda2
Implement IDE-friendly eventing with automatic Dapr fallback
Copilot Aug 3, 2025
213f330
Fix bad test
slombardo Aug 3, 2025
972aaee
Loosen up endpoints. Will replace with app token at some point
slombardo Aug 4, 2025
5ca3ca3
Add a profile to run API with dapr
slombardo Aug 4, 2025
4bc3e58
Move to containers for Dapr sidecar
slombardo Aug 4, 2025
e2aca33
Small change
slombardo Aug 4, 2025
562c0b7
Adjust components path
slombardo Aug 4, 2025
dd23cce
Replace hardcoded event type discovery with flexible EventTypeRegistry
Copilot Aug 4, 2025
99fe5a2
Replace EventTypeRegistry with IFrontDeskEvent interface approach
Copilot Aug 4, 2025
8f495fd
Implement Dapr Outbox pattern for reliable event publishing
Copilot Aug 5, 2025
d33c2c2
Fix outbox connection strings to use same database as EF Core
Copilot Aug 5, 2025
eb4bdc7
Update FrontDesk.Eventing/DaprOutboxService.cs
slombardo Aug 6, 2025
b6f77d9
Update FrontDesk.Eventing/DaprOutboxService.cs
slombardo Aug 6, 2025
8a515a9
Complete DaprOutboxService implementation and remove test artifacts
Copilot Aug 6, 2025
dc95925
Replace keyvault with local env
slombardo Aug 6, 2025
269f8f7
Adjust API to get proper body
slombardo Aug 7, 2025
dae9004
update gitignore
slombardo Aug 7, 2025
545c3e2
minor changes
slombardo Aug 8, 2025
841e5e6
Merge branch 'main' into copilot/fix-223
slombardo Dec 21, 2025
f4cc0c8
Disable Dapr Outbox by default for zero-setup development
Copilot Dec 21, 2025
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
4 changes: 2 additions & 2 deletions .github/workflows/main_frontdesk-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,6 @@ jobs:
with:
app-name: 'FrontDesk-api'
slot-name: 'Production'
package: 'publish'
clean: true
images: 'docker-compose.yml'
is-linux: true
Comment on lines +141 to +142
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

The GitHub Actions workflow is being changed to deploy Docker images using images: 'docker-compose.yml', but docker-compose.yml references an image that doesn't exist yet: myacr.azurecr.io/frontdesk-api:${{ github.sha }}. This variable syntax is for GitHub Actions, not Docker Compose. The workflow needs to build and push the Docker image first, and the docker-compose.yml should use a proper environment variable reference like ${IMAGE_TAG} or the workflow should provide a different deployment mechanism.

Suggested change
images: 'docker-compose.yml'
is-linux: true
package: publish

Copilot uses AI. Check for mistakes.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@ riderModule.iml
/*.user
/ssl/
/nupkgs/
TestResults/
*.trx
/FrontDesk.Api/data/
FrontDesk.WebApp/src/environments/git-info.ts
5 changes: 3 additions & 2 deletions FrontDesk.Api/ContainerConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ public static ContainerBuilder ConfigureContainer(this ContainerBuilder builder,
builder.RegisterType<UserApi>().AsImplementedInterfaces();
builder.RegisterType<WebhookApi>().AsImplementedInterfaces();

// Eventing
builder.RegisterType<InProcessEventPublisher>().As<IEventPublisher>().InstancePerLifetimeScope();
// Eventing - use Dapr everywhere for consistency between dev and prod
builder.RegisterType<DaprEventPublisher>().As<IEventPublisher>().InstancePerLifetimeScope();
builder.RegisterType<DaprOutboxService>().As<IOutboxService>().InstancePerLifetimeScope();
builder
.RegisterAssemblyTypes(typeof(ReservationCreatedAutomationAdded).Assembly)
.AsClosedTypesOf(typeof(IEventHandler<>))
Expand Down
294 changes: 294 additions & 0 deletions FrontDesk.Api/Endpoints/DaprEventEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
using System.Collections;
using Autofac;
using FrontDesk.Eventing;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;

namespace FrontDesk.Api.Endpoints;

public static class DaprEventEndpoints
{
public static void MapDaprEventEndpoints(this IEndpointRouteBuilder app)
{
// Dapr subscription endpoint - restricted to Dapr runtime only
app.MapGet("/dapr/subscribe", GetSubscriptions)
.RequireHost("localhost", "127.0.0.1") // Only allow local requests from Dapr sidecar
.AllowAnonymous()
.ExcludeFromDescription();

// Generic event handler endpoint - restricted to Dapr runtime only
app.MapPost("/events/{eventType}", HandleEvent)
.RequireHost("localhost", "127.0.0.1") // Only allow local requests from Dapr sidecar
.AllowAnonymous()
.ExcludeFromDescription();

// Outbox processing endpoint triggered by Dapr cron binding
app.MapPost("/outbox/process", ProcessOutbox)
.RequireHost("localhost", "127.0.0.1") // Only allow local requests from Dapr sidecar
.AllowAnonymous()
.ExcludeFromDescription();

// Dapr input binding endpoint for cron-triggered outbox processing
app.MapPost("/frontdesk-outbox-cron", ProcessOutboxBinding)
.RequireHost("localhost", "127.0.0.1") // Only allow local requests from Dapr sidecar
.AllowAnonymous()
.ExcludeFromDescription();
}

private static IResult GetSubscriptions(HttpContext context, ILogger<DaprEventPublisher> logger)
{
// Enhanced security validation for Dapr runtime access
var remoteIp = context.Connection.RemoteIpAddress?.ToString();

// Verify request is from local Dapr sidecar
if (!IsLocalRequest(remoteIp))
{
logger.LogWarning("Subscription endpoint accessed from non-local IP: {RemoteIP}", remoteIp);
return Results.Forbid();
}

// TODO: we need to add dapr app token for extra security
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

The RequireHost middleware only validates the Host header, which can be easily spoofed. For production security, this should be combined with network policies or firewall rules. Additionally, the TODO comments on lines 50 and 128 mention adding Dapr app token validation, which is a critical security feature that should be implemented before production use. Consider implementing Dapr's app API token validation.

Copilot uses AI. Check for mistakes.

logger.LogDebug("Providing Dapr subscriptions to {RemoteIP}", remoteIp);

var subscriptions = new[]
{
new
{
pubsubname = "frontdesk-pubsub",
topic = "ReservationCreated",
route = "/events/ReservationCreated"
},
new
{
pubsubname = "frontdesk-pubsub",
topic = "ReservationUpdated",
route = "/events/ReservationUpdated"
},
new
{
pubsubname = "frontdesk-pubsub",
topic = "ReservationDeleted",
route = "/events/ReservationDeleted"
},
new
{
pubsubname = "frontdesk-pubsub",
Comment on lines +71 to +76
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

The documentation mentions "ReservationDeleted" event in multiple places (lines 72, 209, 303), and it's subscribed to in DaprEventEndpoints.cs, but there's no corresponding ReservationDeleted.cs event class in the FrontDesk.Services/Integration/Events directory. Either add the missing event class or remove references to this event from the documentation and subscription list.

Suggested change
topic = "ReservationDeleted",
route = "/events/ReservationDeleted"
},
new
{
pubsubname = "frontdesk-pubsub",

Copilot uses AI. Check for mistakes.
topic = "ReservationCreatedAutomationAdded",
route = "/events/ReservationCreatedAutomationAdded"
},
new
{
pubsubname = "frontdesk-pubsub",
topic = "ReservationTaskCreated",
route = "/events/ReservationTaskCreated"
},
new
{
pubsubname = "frontdesk-pubsub",
topic = "ReservationTaskBackfillCompleted",
route = "/events/ReservationTaskBackfillCompleted"
}
};

return Results.Ok(subscriptions);
}

private static bool IsLocalRequest(string? remoteIp)
{
if (string.IsNullOrEmpty(remoteIp))
return false;

// Check for localhost addresses
return remoteIp == "127.0.0.1" ||
remoteIp == "::1" ||
remoteIp == "localhost" ||
remoteIp.StartsWith("127.") ||
remoteIp.StartsWith("::ffff:127."); // IPv4-mapped IPv6 localhost
}

private static async Task<IResult> HandleEvent(
HttpContext context,
[FromRoute] string eventType,
[FromServices] ILifetimeScope scope,
[FromServices] ILogger<DaprEventPublisher> logger)
{
try
{
// Enhanced security validation for Dapr runtime access
var remoteIp = context.Connection.RemoteIpAddress?.ToString();

// Verify request is from local Dapr sidecar
if (!IsLocalRequest(remoteIp))
{
logger.LogWarning("Event endpoint accessed from non-local IP: {RemoteIP}", remoteIp);
return Results.Forbid();
}

// TODO: we need to add dapr app token for extra security

logger.LogDebug("Received Dapr event of type {EventType} from {RemoteIP}", eventType, remoteIp);

// Read and parse the raw JSON body
var doc = await JsonSerializer.DeserializeAsync<JsonDocument>(context.Request.Body);
var root = doc?.RootElement;
if (root == null)
{
logger.LogError("Empty request body for event type {EventType}", eventType);
return Results.BadRequest("Empty event data");
}

// Extract actual payload from CloudEvent envelope if present
var payload = root.Value.TryGetProperty("data", out var dataElem) ? dataElem : root.Value;

// Find the event type by searching for IFrontDeskEvent implementations
var eventTypeObj = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(assembly => assembly.GetTypes())
.Where(type => typeof(IFrontDeskEvent).IsAssignableFrom(type) && !type.IsInterface)
.FirstOrDefault(type => type.Name.Equals(eventType, StringComparison.OrdinalIgnoreCase));

if (eventTypeObj == null)
{
var availableTypes = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(assembly => assembly.GetTypes())
.Where(type => typeof(IFrontDeskEvent).IsAssignableFrom(type) && !type.IsInterface)
.Select(type => type.Name)
.ToList();

logger.LogWarning("Unknown event type: {EventType}. Available types: {AvailableTypes}",
eventType, string.Join(", ", availableTypes));
return Results.BadRequest($"Unknown event type: {eventType}");
}
Comment on lines +144 to +161
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

The same event type discovery logic using reflection is duplicated here (lines 145-161) and in DaprOutboxService.cs (lines 252-291). This code duplication violates DRY principles and makes maintenance harder. Consider extracting this logic into a shared service or helper class that caches the type mappings.

Copilot uses AI. Check for mistakes.

// Deserialize the event data to the specific event type
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
var eventInstance = JsonSerializer.Deserialize(payload.GetRawText(), eventTypeObj, options);
if (eventInstance == null)
{
logger.LogError("Failed to deserialize event data for type {EventType}", eventType);
return Results.BadRequest("Failed to deserialize event data");
}

// Find all handlers for this event type using reflection to build the generic type
var handlerInterfaceType = typeof(IEventHandler<>).MakeGenericType(eventTypeObj);

if (scope.ResolveOptional(typeof(IEnumerable<>).MakeGenericType(handlerInterfaceType)) is IEnumerable handlers)
{
foreach (var handler in handlers)
{
// Call HandleAsync method on each handler
var handleMethod = handlerInterfaceType.GetMethod("HandleAsync");
if (handleMethod != null)
{
var result = handleMethod.Invoke(handler, [eventInstance]);
if (result is Task task)
{
await task;
}
else
{
logger.LogError("HandleAsync method did not return a Task for event type {EventType}", eventType);
return Results.Problem("Handler did not return a Task");
}
}
}
}

logger.LogDebug("Successfully processed Dapr event of type {EventType}", eventType);
return Results.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing Dapr event of type {EventType}", eventType);
return Results.Problem("Error processing event");
}
}

private static async Task<IResult> ProcessOutbox(HttpContext context, ILifetimeScope scope, ILogger<DaprEventPublisher> logger)
{
// Enhanced security validation for Dapr runtime access
var remoteIp = context.Connection.RemoteIpAddress?.ToString();

// Verify request is from local Dapr sidecar
if (!IsLocalRequest(remoteIp))
{
logger.LogWarning("Outbox processing endpoint accessed from non-local IP: {RemoteIP}", remoteIp);
return Results.Forbid();
}

try
{
var outboxService = scope.ResolveOptional<IOutboxService>();
if (outboxService == null)
{
logger.LogDebug("Outbox service not available, skipping outbox processing");
return Results.Ok(new { processed = 0, message = "Outbox service not configured" });
}

var processedCount = await outboxService.ProcessPendingEventsAsync();

if (processedCount > 0)
{
logger.LogInformation("Processed {Count} events from outbox", processedCount);
}
else
{
logger.LogDebug("No pending events found in outbox");
}

return Results.Ok(new { processed = processedCount });
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing outbox events");
return Results.Problem("Error processing outbox events");
}
}

private static async Task<IResult> ProcessOutboxBinding(HttpContext context, ILifetimeScope scope, ILogger<DaprEventPublisher> logger)
{
// This endpoint is called by Dapr's cron input binding
// The binding data is sent in the request body, but we don't need it for cron

// Enhanced security validation for Dapr runtime access
var remoteIp = context.Connection.RemoteIpAddress?.ToString();

// Verify request is from local Dapr sidecar
if (!IsLocalRequest(remoteIp))
{
logger.LogWarning("Outbox binding endpoint accessed from non-local IP: {RemoteIP}", remoteIp);
return Results.Forbid();
}

try
{
var outboxService = scope.ResolveOptional<IOutboxService>();
if (outboxService == null)
{
logger.LogDebug("Outbox service not available, skipping outbox processing");
return Results.Ok(new { processed = 0, message = "Outbox service not configured" });
}

var processedCount = await outboxService.ProcessPendingEventsAsync();

if (processedCount > 0)
{
logger.LogInformation("Cron-triggered outbox processing completed: {Count} events processed", processedCount);
}
else
{
logger.LogDebug("Cron-triggered outbox processing: no pending events found");
}

return Results.Ok(new { processed = processedCount, source = "cron-binding" });
}
catch (Exception ex)
{
logger.LogError(ex, "Error in cron-triggered outbox processing");
return Results.Problem("Error processing outbox events via cron binding");
}
}
}
12 changes: 6 additions & 6 deletions FrontDesk.Api/Endpoints/OAuthEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@ public static class OAuthEndpoints
{
public static void MapOAuthEndpointsV1(this RouteGroupBuilder api)
{
// Create a group for Hospitable OAuth endpoints with API version 1.0.
var hospitableGroup = api.MapGroup("/oauth")
// Create a group for OAuth endpoints with API version 1.0.
var providerGroup = api.MapGroup("/oauth")
.MapToApiVersion(1.0)
.RequireAuthorization();

// Map the callback endpoint.
hospitableGroup.MapGet("/{providerKey}/callback", async (string providerKey, HttpContext httpContext, IOAuthApi oAuthApi) =>
providerGroup.MapGet("/{providerKey}/callback", async (string providerKey, HttpContext httpContext, IOAuthApi oAuthApi) =>
await oAuthApi.HandleCallbackAsync(httpContext, providerKey))
.WithName("HospitableOAuthCallback")
.WithName("OAuthCallback")
.AllowAnonymous() // Allow anonymous access since this endpoint handles the OAuth callback.
.ExcludeFromDescription();

// Map the authorization URL endpoint.
hospitableGroup.MapGet("/{providerKey}/authorization-url", async (string providerKey, IOAuthApi oAuthApi) =>
providerGroup.MapGet("/{providerKey}/authorization-url", async (string providerKey, IOAuthApi oAuthApi) =>
await oAuthApi.GetAuthorizationUrl(providerKey))
.WithName("HospitableOAuthAuthorizationUrl")
.WithName("OAuthAuthorizationUrl")
.ExcludeFromDescription();
}
}
2 changes: 1 addition & 1 deletion FrontDesk.Api/FrontDesk.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageReference Include="AspNet.Security.OAuth.GitHub" Version="9.4.1"/>
<PackageReference Include="Autofac" Version="9.0.0"/>
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="10.0.0"/>
<PackageReference Include="Dapr.AspNetCore" Version="1.13.0"/>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0"/>
<PackageReference Include="LinqKit.Microsoft.EntityFrameworkCore" Version="9.0.8"/>
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.23.0"/>
Expand Down Expand Up @@ -50,7 +51,6 @@
</ItemGroup>

<ItemGroup>
<Folder Include="components\"/>
<Folder Include="Integrations\"/>
</ItemGroup>
</Project>
Loading