< Summary

Information
Class: Elsa.UserTasks.Endpoints.CompleteEndpoint
Assembly: Elsa.UserTasks
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Endpoints/UserTasksEndpoints.cs
Line coverage
26%
Covered lines: 4
Uncovered lines: 11
Coverable lines: 15
Total lines: 686
Line coverage: 26.6%
Branch coverage
0%
Covered branches: 0
Total branches: 4
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Configure()100%11100%
HandleAsync()0%2040%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Endpoints/UserTasksEndpoints.cs

#LineLine coverage
 1using System.Text.Json;
 2using Elsa.Abstractions;
 3using Elsa.Authorization;
 4using Elsa.Common;
 5using Elsa.UserTasks.Contracts;
 6using Elsa.UserTasks.Models;
 7using Elsa.UserTasks.Options;
 8using Elsa.UserTasks.Permissions;
 9using Elsa.UserTasks.Services;
 10using FastEndpoints;
 11using Microsoft.AspNetCore.Http;
 12using Microsoft.Extensions.Options;
 13
 14namespace Elsa.UserTasks.Endpoints;
 15
 16/// <summary>
 17/// Shared behavior for the authenticated task endpoints: resolve the actor once, and translate a domain
 18/// conflict code into the single canonical HTTP shape.
 19/// </summary>
 20internal static class UserTaskEndpointHelpers
 21{
 22    public static UserTaskQueryScopeKind ParseScope(string? value) => value?.Trim().ToLowerInvariant() switch
 23    {
 24        "available" => UserTaskQueryScopeKind.Available,
 25        "history" => UserTaskQueryScopeKind.History,
 26        "all" => UserTaskQueryScopeKind.All,
 27        "needs-attention" or "needsattention" => UserTaskQueryScopeKind.NeedsAttention,
 28        _ => UserTaskQueryScopeKind.Assigned
 29    };
 30
 31    /// <summary>Unknown status values are dropped rather than rejected, so a stale bookmark still loads.</summary>
 32    public static IReadOnlyCollection<UserTaskStatus> ParseStatuses(IEnumerable<string>? values) => values == null
 33        ? []
 34        : values.Select(value => Enum.TryParse<UserTaskStatus>(value, ignoreCase: true, out var parsed) ? parsed : (User
 35            .Where(x => x != null)
 36            .Select(x => x!.Value)
 37            .Distinct()
 38            .ToArray();
 39
 40    public static UserTaskQuery ApplyDueFilter(UserTaskQuery query, string? due, DateTimeOffset now) => due?.Trim().ToLo
 41    {
 42        "overdue" => query with { OnlyOverdue = true },
 43        "nodueDate" or "nodue" or "nodate" => query with { OnlyWithoutDueDate = true },
 44        "today" => query with { DueFrom = now.Date, DueTo = now.Date.AddDays(1).AddTicks(-1) },
 45        "thisweek" => query with { DueFrom = now.Date, DueTo = now.Date.AddDays(7).AddTicks(-1) },
 46        _ => query
 47    };
 48
 49    public static UserTaskParticipantType? ParseParticipantType(string? value) => value?.Trim().ToLowerInvariant() switc
 50    {
 51        "group" => UserTaskParticipantType.Group,
 52        "user" => UserTaskParticipantType.User,
 53        _ => null
 54    };
 55}
 56
 57internal abstract class UserTaskEndpointBase<TRequest, TResponse> : ElsaEndpoint<TRequest, TResponse>
 58    where TRequest : notnull, new()
 59    where TResponse : notnull
 60{
 61    protected async Task SendConflictAsync(string code, CancellationToken cancellationToken) =>
 62        await HttpContext.Response.SendAsync(UserTaskErrors.Describe(code), UserTaskErrors.StatusCodeFor(code), cancella
 63
 64    /// <summary>
 65    /// Sends the canonical command envelope. Terminal commands answer <c>202</c> because the workflow resumes
 66    /// out of band; clients observe the final state through requery or invalidation.
 67    /// </summary>
 68    protected async Task SendOperationAsync(UserTaskOperationResult result, UserTaskActor actor, IUserTaskAccessPolicy p
 69    {
 70        if (!result.Accepted)
 71        {
 72            await SendConflictAsync(result.ConflictCode ?? "conflict", cancellationToken);
 73            return;
 74        }
 75
 76        var summary = await UserTaskModelMapper.ToSummaryAsync(result.Task, actor, policy, cancellationToken);
 77        var response = new UserTaskOperationResponse(result.Operation.OperationId, accepted ? "accepted" : "completed", 
 78        await HttpContext.Response.SendAsync(response, accepted ? StatusCodes.Status202Accepted : StatusCodes.Status200O
 79    }
 80}
 81
 82internal sealed class FeatureCapabilitiesEndpoint(IUserTaskIdentityResolver identityResolver, IOptions<UserTasksOptions>
 83    : ElsaEndpointWithoutRequest<UserTaskFeatureCapabilities>
 84{
 85    public override void Configure()
 86    {
 87        Get("/user-tasks/capabilities");
 88        RequirePermission(UserTasksResourcePermissions.UserTasks, CoreVerbs.View);
 89    }
 90
 91    public override async Task HandleAsync(CancellationToken cancellationToken)
 92    {
 93        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 94        if (actor == null)
 95        {
 96            await Send.UnauthorizedAsync(cancellationToken);
 97            return;
 98        }
 99
 100        var settings = options.Value;
 101        bool Holds(string verb) => actor.HasPermission(UserTasksResourcePermissions.UserTasks, verb);
 102        var isManager = actor.IsManager && Holds(UserTaskVerbs.Supervise);
 103        // The descriptor is advisory: it decides what the client renders, never what the server allows.
 104        await Send.OkAsync(new UserTaskFeatureCapabilities
 105        {
 106            Enabled = true,
 107            CanList = Holds(CoreVerbs.View),
 108            CanRead = Holds(CoreVerbs.View),
 109            CanReadAll = isManager,
 110            CanClaim = Holds(UserTaskVerbs.Claim),
 111            CanRelease = Holds(UserTaskVerbs.Claim),
 112            CanComplete = Holds(UserTaskVerbs.Complete),
 113            CanAssign = Holds(UserTaskVerbs.Assign),
 114            CanUpdate = Holds(CoreVerbs.Update),
 115            CanCancel = Holds(UserTaskVerbs.Cancel),
 116            CanCreateGuestLinks = Holds(UserTaskVerbs.Invite),
 117            CanViewProtected = Holds(CoreVerbs.View),
 118            ParticipantPicker = actor.HasPermission(UserTasksResourcePermissions.Participants, CoreVerbs.View) && direct
 119            Realtime = settings.RealtimeEnabled,
 120            PollingIntervalSeconds = settings.PollingIntervalSeconds
 121        }, cancellationToken);
 122    }
 123}
 124
 125internal sealed class ListEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, ISystemClock cl
 126    : ElsaEndpoint<ListUserTasksRequest, UserTaskListResponse>
 127{
 128    public override void Configure()
 129    {
 130        Get("/user-tasks");
 131        RequirePermission(UserTasksResourcePermissions.UserTasks, CoreVerbs.View);
 132    }
 133
 134    public override async Task HandleAsync(ListUserTasksRequest request, CancellationToken cancellationToken)
 135    {
 136        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 137        if (actor == null)
 138        {
 139            await Send.UnauthorizedAsync(cancellationToken);
 140            return;
 141        }
 142
 143        var query = new UserTaskQuery
 144        {
 145            TenantId = actor.Subject.TenantId,
 146            Cursor = request.Cursor,
 147            Limit = request.Limit,
 148            Sort = request.Sort ?? "created",
 149            Descending = string.Equals(request.Direction, "desc", StringComparison.OrdinalIgnoreCase),
 150            Statuses = UserTaskEndpointHelpers.ParseStatuses(request.Status),
 151            PriorityFrom = request.PriorityFrom,
 152            PriorityTo = request.PriorityTo,
 153            DueFrom = request.From,
 154            DueTo = request.To,
 155            WorkflowDefinitionId = request.WorkflowDefinitionId,
 156            WorkflowInstanceId = request.WorkflowInstanceId,
 157            Reference = request.Reference,
 158            TaskType = request.TaskType,
 159            Search = request.Search,
 160            IncludeTotalCount = request.IncludeTotalCount
 161        };
 162        query = UserTaskEndpointHelpers.ApplyDueFilter(query, request.Due, clock.UtcNow);
 163
 164        var scope = UserTaskEndpointHelpers.ParseScope(request.Scope);
 165        var result = await manager.QueryAsync(query, scope, actor, cancellationToken);
 166        if (result == null)
 167        {
 168            // The requested scope is not available to this actor (for example a non-manager asking for
 169            // `all`). This is an authorization outcome, not an empty page.
 170            await Send.ForbiddenAsync(cancellationToken);
 171            return;
 172        }
 173
 174        await Send.OkAsync(new UserTaskListResponse(result.Items, result.NextCursor, result.TotalCount), cancellationTok
 175    }
 176}
 177
 178internal sealed class GetEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver) : ElsaEndpointWi
 179{
 180    public override void Configure()
 181    {
 182        Get("/user-tasks/{taskId}");
 183        RequirePermission(UserTasksResourcePermissions.UserTasks, CoreVerbs.View);
 184    }
 185
 186    public override async Task HandleAsync(CancellationToken cancellationToken)
 187    {
 188        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 189        var task = actor == null ? null : await manager.GetAsync(actor.Subject.TenantId, Route<string>("taskId")!, actor
 190        if (task == null)
 191        {
 192            // Concealment is deliberate: an unauthorized caller must not be able to tell an existing task
 193            // from a missing one.
 194            await Send.NotFoundAsync(cancellationToken);
 195            return;
 196        }
 197        await Send.OkAsync(task, cancellationToken);
 198    }
 199}
 200
 201internal sealed class CapabilitiesEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver) : ElsaE
 202{
 203    public override void Configure()
 204    {
 205        Get("/user-tasks/{taskId}/capabilities");
 206        RequirePermission(UserTasksResourcePermissions.UserTasks, CoreVerbs.View);
 207    }
 208
 209    public override async Task HandleAsync(CancellationToken cancellationToken)
 210    {
 211        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 212        var result = actor == null ? null : await manager.GetCapabilitiesAsync(actor.Subject.TenantId, Route<string>("ta
 213        if (result == null)
 214        {
 215            await Send.NotFoundAsync(cancellationToken);
 216            return;
 217        }
 218        await Send.OkAsync(result, cancellationToken);
 219    }
 220}
 221
 222internal sealed class ListEventsEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver) : ElsaEnd
 223{
 224    public override void Configure()
 225    {
 226        Get("/user-tasks/{taskId}/events");
 227        RequirePermission(UserTasksResourcePermissions.UserTasks, CoreVerbs.View);
 228    }
 229
 230    public override async Task HandleAsync(CancellationToken cancellationToken)
 231    {
 232        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 233        var result = actor == null
 234            ? null
 235            : await manager.GetEventsAsync(actor.Subject.TenantId, Route<string>("taskId")!, Query<string>("cursor", isR
 236                Query<int?>("limit", isRequired: false) ?? 50, actor, cancellationToken);
 237        if (result == null)
 238        {
 239            await Send.NotFoundAsync(cancellationToken);
 240            return;
 241        }
 242        await Send.OkAsync(result, cancellationToken);
 243    }
 244}
 245
 246internal sealed class ClaimEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, IUserTaskAcces
 247    : UserTaskEndpointBase<UserTaskMutationApiRequest, UserTaskOperationResponse>
 248{
 249    public override void Configure()
 250    {
 251        Post("/user-tasks/{taskId}/claim");
 252        RequirePermission(UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Claim);
 253    }
 254
 255    public override async Task HandleAsync(UserTaskMutationApiRequest request, CancellationToken cancellationToken)
 256    {
 257        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 258        if (actor == null)
 259        {
 260            await Send.UnauthorizedAsync(cancellationToken);
 261            return;
 262        }
 263        var result = await manager.ClaimAsync(actor.Subject.TenantId, Route<string>("taskId")!, new(request.ExpectedRevi
 264        await SendOperationAsync(result, actor, policy, accepted: false, cancellationToken);
 265    }
 266}
 267
 268internal sealed class ReleaseEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, IUserTaskAcc
 269    : UserTaskEndpointBase<UserTaskMutationApiRequest, UserTaskOperationResponse>
 270{
 271    public override void Configure()
 272    {
 273        Post("/user-tasks/{taskId}/release");
 274        RequirePermission(UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Claim);
 275    }
 276
 277    public override async Task HandleAsync(UserTaskMutationApiRequest request, CancellationToken cancellationToken)
 278    {
 279        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 280        if (actor == null)
 281        {
 282            await Send.UnauthorizedAsync(cancellationToken);
 283            return;
 284        }
 285        var result = await manager.ReleaseAsync(actor.Subject.TenantId, Route<string>("taskId")!, new(request.ExpectedRe
 286        await SendOperationAsync(result, actor, policy, accepted: false, cancellationToken);
 287    }
 288}
 289
 290internal sealed class AssignEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, IUserTaskAcce
 291    : UserTaskEndpointBase<AssignUserTaskApiRequest, UserTaskOperationResponse>
 292{
 293    public override void Configure()
 294    {
 295        Post("/user-tasks/{taskId}/assign");
 296        RequirePermission(UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Assign);
 297    }
 298
 299    public override async Task HandleAsync(AssignUserTaskApiRequest request, CancellationToken cancellationToken)
 300    {
 301        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 302        if (actor == null)
 303        {
 304            await Send.UnauthorizedAsync(cancellationToken);
 305            return;
 306        }
 307        if (request.Assignee is null || string.IsNullOrWhiteSpace(request.Assignee.Id))
 308        {
 309            await SendConflictAsync("invalid-action", cancellationToken);
 310            return;
 311        }
 312
 313        // The tenant is taken from the caller's own scope and never from the body, so a cross-tenant
 314        // reference cannot be constructed over the wire at all.
 315        var assignee = new ParticipantReference(
 316            actor.Subject.TenantId,
 317            string.IsNullOrWhiteSpace(request.Assignee.Provider) ? actor.Subject.Provider : request.Assignee.Provider!,
 318            string.Equals(request.Assignee.Kind, UserTaskParticipantSummary.GroupKind, StringComparison.OrdinalIgnoreCas
 319            request.Assignee.Id,
 320            request.Assignee.DisplayName);
 321        var result = await manager.AssignAsync(actor.Subject.TenantId, Route<string>("taskId")!,
 322            new(request.ExpectedRevision, assignee, request.Reason, request.OperationId), actor, cancellationToken);
 323        await SendOperationAsync(result, actor, policy, accepted: false, cancellationToken);
 324    }
 325}
 326
 327internal sealed class ScheduleEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, IUserTaskAc
 328    : UserTaskEndpointBase<ScheduleUserTaskApiRequest, UserTaskOperationResponse>
 329{
 330    public override void Configure()
 331    {
 332        Patch("/user-tasks/{taskId}");
 333        RequirePermission(UserTasksResourcePermissions.UserTasks, CoreVerbs.Update);
 334    }
 335
 336    public override async Task HandleAsync(ScheduleUserTaskApiRequest request, CancellationToken cancellationToken)
 337    {
 338        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 339        if (actor == null)
 340        {
 341            await Send.UnauthorizedAsync(cancellationToken);
 342            return;
 343        }
 344        var result = await manager.UpdateSchedulingAsync(actor.Subject.TenantId, Route<string>("taskId")!,
 345            new(request.ExpectedRevision, request.Priority, request.DueAt, request.OperationId), actor, cancellationToke
 346        await SendOperationAsync(result, actor, policy, accepted: false, cancellationToken);
 347    }
 348}
 349
 2350internal sealed class CompleteEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, IUserTaskAc
 351    : UserTaskEndpointBase<CompleteUserTaskApiRequest, UserTaskOperationResponse>
 352{
 353    public override void Configure()
 354    {
 2355        Post("/user-tasks/{taskId}/complete");
 2356        RequirePermission(UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Complete);
 2357    }
 358
 359    public override async Task HandleAsync(CompleteUserTaskApiRequest request, CancellationToken cancellationToken)
 360    {
 0361        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 0362        if (actor == null)
 363        {
 0364            await Send.UnauthorizedAsync(cancellationToken);
 0365            return;
 366        }
 0367        if (string.IsNullOrWhiteSpace(request.OperationId))
 368        {
 0369            await SendConflictAsync("invalid-action", cancellationToken);
 0370            return;
 371        }
 0372        var result = await manager.CompleteAsync(actor.Subject.TenantId, Route<string>("taskId")!,
 0373            new(request.ExpectedRevision, request.OperationId, request.ActionKey, request.Data), actor, cancellationToke
 0374        await SendOperationAsync(result, actor, policy, accepted: true, cancellationToken);
 0375    }
 376}
 377
 378internal sealed class CancelEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, IUserTaskAcce
 379    : UserTaskEndpointBase<CancelUserTaskApiRequest, UserTaskOperationResponse>
 380{
 381    public override void Configure()
 382    {
 383        Post("/user-tasks/{taskId}/cancel");
 384        RequirePermission(UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Cancel);
 385    }
 386
 387    public override async Task HandleAsync(CancelUserTaskApiRequest request, CancellationToken cancellationToken)
 388    {
 389        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 390        if (actor == null)
 391        {
 392            await Send.UnauthorizedAsync(cancellationToken);
 393            return;
 394        }
 395        var result = await manager.CancelAsync(actor.Subject.TenantId, Route<string>("taskId")!,
 396            new(request.ExpectedRevision, request.OperationId, request.Reason), actor, cancellationToken);
 397        await SendOperationAsync(result, actor, policy, accepted: true, cancellationToken);
 398    }
 399}
 400
 401internal sealed class RetryResolutionEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver, IUse
 402    : UserTaskEndpointBase<UserTaskMutationApiRequest, UserTaskOperationResponse>
 403{
 404    public override void Configure()
 405    {
 406        Post("/user-tasks/{taskId}/retry-resolution");
 407        RequirePermission(UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Supervise);
 408    }
 409
 410    public override async Task HandleAsync(UserTaskMutationApiRequest request, CancellationToken cancellationToken)
 411    {
 412        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 413        if (actor == null)
 414        {
 415            await Send.UnauthorizedAsync(cancellationToken);
 416            return;
 417        }
 418        var result = await manager.RetryResolutionAsync(actor.Subject.TenantId, Route<string>("taskId")!, new(request.Ex
 419        await SendOperationAsync(result, actor, policy, accepted: true, cancellationToken);
 420    }
 421}
 422
 423internal sealed class RevealFieldEndpoint(IUserTaskManager manager, IUserTaskIdentityResolver identityResolver)
 424    : ElsaEndpoint<RevealUserTaskFieldApiRequest, RevealUserTaskFieldResponse>
 425{
 426    public override void Configure()
 427    {
 428        Post("/user-tasks/{taskId}/reveal");
 429        RequirePermission(UserTasksResourcePermissions.UserTasks, CoreVerbs.View);
 430    }
 431
 432    public override async Task HandleAsync(RevealUserTaskFieldApiRequest request, CancellationToken cancellationToken)
 433    {
 434        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 435        var value = actor == null || string.IsNullOrWhiteSpace(request.FieldKey)
 436            ? null
 437            : await manager.RevealFieldAsync(actor.Subject.TenantId, Route<string>("taskId")!, request.FieldKey, actor, 
 438        if (value == null)
 439        {
 440            await Send.NotFoundAsync(cancellationToken);
 441            return;
 442        }
 443        await Send.OkAsync(new RevealUserTaskFieldResponse(request.FieldKey, value), cancellationToken);
 444    }
 445}
 446
 447internal sealed class IssueInvitationEndpoint(IUserTaskInvitationService invitations, IUserTaskIdentityResolver identity
 448    : ElsaEndpoint<IssueUserTaskInvitationApiRequest, UserTaskInvitationIssueResult>
 449{
 450    public override void Configure()
 451    {
 452        Post("/user-tasks/{taskId}/invitations");
 453        RequirePermission(UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Invite);
 454    }
 455
 456    public override async Task HandleAsync(IssueUserTaskInvitationApiRequest request, CancellationToken cancellationToke
 457    {
 458        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 459        if (actor == null)
 460        {
 461            await Send.UnauthorizedAsync(cancellationToken);
 462            return;
 463        }
 464
 465        var result = await invitations.IssueAsync(actor.Subject.TenantId, Route<string>("taskId")!,
 466            new(request.ExpectedRevision, request.VerifierName, request.AllowedActions,
 467                request.Recipient, request.Lifetime, request.OperationId), actor, cancellationToken);
 468        if (result == null)
 469        {
 470            await Send.NotFoundAsync(cancellationToken);
 471            return;
 472        }
 473        // The response carries metadata only. The secret leaves through the dispatcher, never the API.
 474        await HttpContext.Response.SendAsync(result, StatusCodes.Status201Created, cancellation: cancellationToken);
 475    }
 476}
 477
 478internal sealed class ListInvitationsEndpoint(IUserTaskInvitationService invitations, IUserTaskIdentityResolver identity
 479    : ElsaEndpointWithoutRequest<UserTaskInvitationListResponse>
 480{
 481    public override void Configure()
 482    {
 483        Get("/user-tasks/{taskId}/invitations");
 484        RequirePermission(UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Invite);
 485    }
 486
 487    public override async Task HandleAsync(CancellationToken cancellationToken)
 488    {
 489        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 490        var result = actor == null ? null : await invitations.ListAsync(actor.Subject.TenantId, Route<string>("taskId")!
 491        if (result == null)
 492        {
 493            await Send.NotFoundAsync(cancellationToken);
 494            return;
 495        }
 496        await Send.OkAsync(new UserTaskInvitationListResponse(result), cancellationToken);
 497    }
 498}
 499
 500internal sealed class RevokeInvitationEndpoint(IUserTaskInvitationService invitations, IUserTaskIdentityResolver identit
 501    : ElsaEndpoint<UserTaskMutationApiRequest>
 502{
 503    public override void Configure()
 504    {
 505        Delete("/user-tasks/{taskId}/invitations/{invitationId}");
 506        RequirePermission(UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Invite);
 507    }
 508
 509    public override async Task HandleAsync(UserTaskMutationApiRequest request, CancellationToken cancellationToken)
 510    {
 511        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 512        if (actor == null)
 513        {
 514            await Send.UnauthorizedAsync(cancellationToken);
 515            return;
 516        }
 517        var revoked = await invitations.RevokeAsync(actor.Subject.TenantId, Route<string>("taskId")!, Route<string>("inv
 518        if (!revoked)
 519            await Send.NotFoundAsync(cancellationToken);
 520        else
 521            await Send.NoContentAsync(cancellationToken);
 522    }
 523}
 524
 525internal sealed class ListParticipantsEndpoint(IUserTaskParticipantDirectory directory, IUserTaskIdentityResolver identi
 526    : ElsaEndpoint<UserTaskParticipantLookupApiRequest, UserTaskParticipantSearchResponse>
 527{
 528    public override void Configure()
 529    {
 530        Get("/user-task-participants");
 531        RequirePermission(UserTasksResourcePermissions.Participants, CoreVerbs.View);
 532    }
 533
 534    public override async Task HandleAsync(UserTaskParticipantLookupApiRequest request, CancellationToken cancellationTo
 535    {
 536        var actor = await identityResolver.ResolveAsync(User, cancellationToken);
 537        if (actor == null)
 538        {
 539            await Send.UnauthorizedAsync(cancellationToken);
 540            return;
 541        }
 542
 543        // A host without a directory answers with an empty page rather than an identity-module error, so the
 544        // picker degrades to a plain reference editor instead of breaking the page.
 545        var result = await directory.SearchAsync(new(actor.Subject.TenantId, request.Search,
 546            UserTaskEndpointHelpers.ParseParticipantType(request.Type), request.Cursor,
 547            Math.Clamp(request.Limit <= 0 ? 50 : request.Limit, 1, 200)), cancellationToken);
 548        var items = result.Items
 549            .Where(x => string.Equals(x.TenantId, actor.Subject.TenantId, StringComparison.Ordinal))
 550            .Select(x => UserTaskParticipantSummary.From(x)!)
 551            .ToArray();
 552        await Send.OkAsync(new UserTaskParticipantSearchResponse(items, result.NextCursor), cancellationToken);
 553    }
 554}
 555
 556/// <summary>
 557/// Anonymous invitation surface. Both endpoints are rate limited per caller and answer with the same generic
 558/// shape for valid and invalid tokens.
 559/// </summary>
 560internal abstract class AnonymousInvitationEndpointBase<TRequest, TResponse> : Endpoint<TRequest, TResponse>
 561    where TRequest : notnull
 562    where TResponse : notnull
 563{
 564    protected async Task<bool> TryAcquireAsync(IUserTaskInvitationRateLimiter limiter, CancellationToken cancellationTok
 565    {
 566        // Partition by remote address, not by token: probing many tokens from one host drains one budget.
 567        var partition = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
 568        if (await limiter.TryAcquireAsync(partition, cancellationToken))
 569            return true;
 570
 571        await HttpContext.Response.SendAsync(UserTaskErrors.Describe("invitation-unavailable"), StatusCodes.Status429Too
 572        return false;
 573    }
 574}
 575
 576internal sealed class DescribeInvitationEndpoint(IUserTaskInvitationService invitations, IUserTaskInvitationRateLimiter 
 577    : AnonymousInvitationEndpointBase<EmptyRequest, UserTaskInvitationChallengeDescriptor>
 578{
 579    public override void Configure()
 580    {
 581        Get("/user-task-invitations/{token}");
 582        AllowAnonymous();
 583    }
 584
 585    public override async Task HandleAsync(EmptyRequest request, CancellationToken cancellationToken)
 586    {
 587        if (!await TryAcquireAsync(limiter, cancellationToken))
 588            return;
 589        await Send.OkAsync(await invitations.DescribeAsync(Route<string>("token")!, cancellationToken), cancellationToke
 590    }
 591}
 592
 593internal sealed class VerifyInvitationEndpoint(IUserTaskInvitationService invitations, IUserTaskInvitationRateLimiter li
 594    : AnonymousInvitationEndpointBase<VerifyUserTaskInvitationApiRequest, UserTaskGuestSessionResponse>
 595{
 596    public override void Configure()
 597    {
 598        Post("/user-task-invitations/{token}/verify");
 599        AllowAnonymous();
 600    }
 601
 602    public override async Task HandleAsync(VerifyUserTaskInvitationApiRequest request, CancellationToken cancellationTok
 603    {
 604        if (!await TryAcquireAsync(limiter, cancellationToken))
 605            return;
 606
 607        var result = await invitations.VerifyAsync(new(Route<string>("token")!, request.Code, request.State), cancellati
 608        if (!result.Succeeded)
 609        {
 610            // One shape for missing, expired, consumed, revoked, and wrong-code. No existence oracle.
 611            await HttpContext.Response.SendAsync(UserTaskErrors.Describe("invitation-unavailable"), StatusCodes.Status40
 612            return;
 613        }
 614        await Send.OkAsync(new UserTaskGuestSessionResponse(result.SessionToken, result.TaskId, result.ExpiresAt), cance
 615    }
 616}
 617
 618/// <summary>
 619/// Guest task surface. The presented session credential identifies the task, so no task ID appears in the
 620/// route and a guest can never address a task other than the one their invitation was issued for.
 621/// </summary>
 622internal sealed class GuestTaskEndpoint(IUserTaskManager manager, UserTaskGuestActorResolver guestResolver)
 623    : EndpointWithoutRequest<UserTaskDetail>
 624{
 625    public override void Configure()
 626    {
 627        Get("/user-task-sessions/current");
 628        AllowAnonymous();
 629    }
 630
 631    public override async Task HandleAsync(CancellationToken cancellationToken)
 632    {
 633        var actor = await guestResolver.ResolveAsync(UserTaskGuestActorResolver.ReadCredential(HttpContext.Request.Heade
 634        if (actor?.GuestTaskId == null)
 635        {
 636            await Send.UnauthorizedAsync(cancellationToken);
 637            return;
 638        }
 639
 640        var detail = await manager.GetAsync(actor.Subject.TenantId, actor.GuestTaskId, actor, cancellationToken);
 641        if (detail == null)
 642        {
 643            await Send.NotFoundAsync(cancellationToken);
 644            return;
 645        }
 646        await Send.OkAsync(detail, cancellationToken);
 647    }
 648}
 649
 650internal sealed class GuestCompleteEndpoint(IUserTaskManager manager, UserTaskGuestActorResolver guestResolver, IUserTas
 651    : Endpoint<CompleteUserTaskApiRequest, UserTaskOperationResponse>
 652{
 653    public override void Configure()
 654    {
 655        Post("/user-task-sessions/current/complete");
 656        AllowAnonymous();
 657    }
 658
 659    public override async Task HandleAsync(CompleteUserTaskApiRequest request, CancellationToken cancellationToken)
 660    {
 661        var actor = await guestResolver.ResolveAsync(UserTaskGuestActorResolver.ReadCredential(HttpContext.Request.Heade
 662        if (actor?.GuestTaskId == null)
 663        {
 664            await Send.UnauthorizedAsync(cancellationToken);
 665            return;
 666        }
 667        if (string.IsNullOrWhiteSpace(request.OperationId))
 668        {
 669            await HttpContext.Response.SendAsync(UserTaskErrors.Describe("invalid-action"), StatusCodes.Status422Unproce
 670            return;
 671        }
 672
 673        var result = await manager.CompleteAsync(actor.Subject.TenantId, actor.GuestTaskId,
 674            new(request.ExpectedRevision, request.OperationId, request.ActionKey, request.Data), actor, cancellationToke
 675        if (!result.Accepted)
 676        {
 677            var code = result.ConflictCode ?? "conflict";
 678            await HttpContext.Response.SendAsync(UserTaskErrors.Describe(code), UserTaskErrors.StatusCodeFor(code), canc
 679            return;
 680        }
 681
 682        var summary = await UserTaskModelMapper.ToSummaryAsync(result.Task, actor, policy, cancellationToken);
 683        await HttpContext.Response.SendAsync(new UserTaskOperationResponse(result.Operation.OperationId, "accepted", res
 684            StatusCodes.Status202Accepted, cancellation: cancellationToken);
 685    }
 686}