fullstackhero/dotnet-starter-kit · error · InvalidOperationException

Only the author or a moderator can delete.

Error message

Only the author or a moderator can delete.

What it means

Message.SoftDelete permits deletion only by the message's author or by a moderator (isModerator == true); anyone else throws InvalidOperationException. Note it is idempotent for already-deleted messages (early return). This is the ownership/moderation guard for message removal.

Solutions

  1. Pass the caller's actual moderator status (from claims/permissions) into SoftDelete, not a hardcoded false.
  2. Check authorship or moderator rights before calling SoftDelete and return 403 with a clear message otherwise.
  3. Catch InvalidOperationException in the handler and translate to 403 Forbidden instead of 500.
  4. Centralize the permission check (e.g. a MustBeAuthorOrModerator policy) so all delete paths compute isModerator consistently.

Example fix

// before
message.SoftDelete(currentUserId, isModerator: false);
// after
var isModerator = user.HasPermission(ChatPermissions.DeleteAnyMessage);
if (!isModerator && message.AuthorUserId != currentUserId)
{
    throw new ForbiddenAccessException("Only the author or a moderator can delete.");
}
message.SoftDelete(currentUserId, isModerator);
Defensive patterns

Strategy: validation

Validate before calling

public static bool CanSoftDelete(Domain.Message m, string userId, bool isModerator) => m.DeletedAtUtc is null && (isModerator || string.Equals(m.AuthorUserId, userId, StringComparison.Ordinal));

Type guard

if (!isModerator && message.AuthorUserId != currentUserId) throw new ForbiddenAccessException("Only the author or a moderator can delete.");

Try / catch

try { message.SoftDelete(userId, isModerator); } catch (InvalidOperationException ex) when (ex.Message.Contains("author or a moderator")) { throw new ForbiddenAccessException(ex.Message); }

Prevention

When it happens

Trigger: Calling message.SoftDelete(deletingUserId, isModerator: false) where deletingUserId differs from AuthorUserId — e.g. a regular user trying to delete another user's message, or a handler failing to pass the caller's moderator flag.

Common situations: Moderator role claim not mapped to the isModerator parameter (always false); missing endpoint authorization so non-owners reach the aggregate; client UI showing delete buttons on others' messages; users attempting to delete messages in channels they moderate but where isModerator wasn't computed.

Understand the failure class

Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.

Related errors


AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15). Data as JSON: /api/errors/cfdb504720ae11dc. Report an issue: GitHub.

Appendix: source

Thrown at src/Modules/Chat/Modules.Chat/Domain/Message.cs:102

        }
        if (!string.Equals(AuthorUserId, editingUserId, StringComparison.Ordinal))
        {
            throw new InvalidOperationException("Only the author can edit a message.");
        }
        ArgumentException.ThrowIfNullOrWhiteSpace(newBody);

        Body = newBody.Trim();
        EditedAtUtc = DateTime.UtcNow;
        AddDomainEvent(DomainEvent.Create((id, ts) =>
            new MessageEditedDomainEvent(ChannelId, Id, AuthorUserId, id, ts)));
    }

    public void SoftDelete(string deletingUserId, bool isModerator)
    {
        if (DeletedAtUtc.HasValue) return;
        if (!isModerator && !string.Equals(AuthorUserId, deletingUserId, StringComparison.Ordinal))
        {
            throw new InvalidOperationException("Only the author or a moderator can delete.");
        }
        DeletedAtUtc = DateTime.UtcNow;
        Body = null;
        AddDomainEvent(DomainEvent.Create((id, ts) =>
            new MessageDeletedDomainEvent(ChannelId, Id, AuthorUserId, id, ts)));
    }

    public MessageAttachment AddAttachment(Guid? fileAssetId, string url, string contentType, string fileName, long sizeBytes)
    {
        var attachment = MessageAttachment.Create(Id, fileAssetId, url, contentType, fileName, sizeBytes);
        _attachments.Add(attachment);
        return attachment;
    }

    /// <summary>
    /// Toggle-on a reaction. Returns the new <see cref="MessageReaction"/>, or <c>null</c> if the
    /// (user, emoji) pair already exists — the unique index would reject the duplicate row.
    /// </summary>

View on GitHub (pinned to 3f2959e683)