Replies a post's author approves (FEP-5624), checked live with PeerTube

PeerTube puts canReply on a video whose comments wait for approval, and answers each comment with ApproveReply. A
persona's reply to such a post now waits (privapub.approval: pending), its Create going to the author alone; the
author's ApproveReply, signed by the author and naming the post answered, lets it out to its audience with
replyApproval, and RejectReply leaves it ours. A null canReply (PeerTube's open comments) says nothing; an empty one
refuses. The PeerTube scenario holds a comment for review and approves it through PeerTube's API (28 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 16:16:34 +02:00
1 parent 5d5f1d6909
commit a3a66b6db2
17 files changed
+256 -25

No files matched your search

@@ -27,7 +27,9 @@ namespace PrivaPub.Domain.Statuses
// GoToSocial's interaction policies (canReply, canLike, canAnnounce): who may answer, like or boost a post at once, who
// must ask its author first, and who may not. Asked, the author answers Accept with an authorization (`result`), which
// the interaction then carries for everyone else (replyAuthorization, likeAuthorization, announceAuthorization), or
// Reject. Our own posts state no such policy: anyone may.
// Reject. And FEP-5624's canReply (PeerTube's): who may reply, the reply itself going to the author alone, who answers
// ApproveReply, which the reply then carries (replyApproval), or RejectReply. Our own posts state no such policy:
// anyone may.
public interface IInteractionApprovals
{
Task<QuotePermission> Judge(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token);
@@ -68,9 +70,20 @@ namespace PrivaPub.Domain.Statuses
_ => post.AnnouncePolicy
};
// FEP-5624's canReply, where GoToSocial's policy says nothing of replies
static bool ApprovesReplies(PostEntity target, InteractionKind kind) =>
kind == InteractionKind.Reply && target.ReplyPolicy == default && target.ReplyApprovals != default;
public async Task<QuotePermission> Judge(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token)
{
if (!target.IsFederatedCopy || Rule(target, kind) is not { } rule)
if (!target.IsFederatedCopy)
return QuotePermission.Granted;
// those it names, or mentions, may reply, and every reply waits for its author's approval
if (ApprovesReplies(target, kind))
return await Includes(target.ReplyApprovals.Manual, target, actor, token) || target.Mentions.Any(m => m.ActorURI == actor.Uri)
? QuotePermission.AskFirst
: QuotePermission.Denied;
if (Rule(target, kind) is not { } rule)
return QuotePermission.Granted;
if (await Includes(rule.Automatic, target, actor, token))
return QuotePermission.Granted;
@@ -102,6 +115,13 @@ namespace PrivaPub.Domain.Statuses
var author = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == target.ActorURI).ExecuteFirstAsync(token);
if (string.IsNullOrEmpty(author?.InboxURL))
return;
// FEP-5624: the reply itself is the question, to the author alone
if (ApprovesReplies(target, kind))
{
await _delivery.Enqueue(actor, new[] { author.InboxURL }, ActivityPubRenderer.Create(actor, (JsonObject)interaction.DeepClone(), $"create-{localId}"),
token);
return;
}
var instrument = (JsonObject)interaction.DeepClone();
instrument.Remove("@context");
await _delivery.Enqueue(actor, new[] { author.InboxURL }, new JsonObject
@@ -177,14 +197,28 @@ namespace PrivaPub.Domain.Statuses
await DB.Default.Update<PostEntity>().MatchID(target.ID).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token);
return true;
}
var authorization = Id(answer["result"]);
var interactionUri = kind == InteractionKind.Reply ? post.ObjectURI : post.ActivityURI;
if (authorization == default || !await Verified(authorization, interactionUri, target, token))
return true;
post.Approval = ApprovalState.Accepted;
post.ApprovalURI = authorization;
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.Approval, ApprovalState.Accepted).Modify(p => p.ApprovalURI, authorization)
.ExecuteAsync(token);
// FEP-5624's ApproveReply is its own stamp, from the author (the delivery's signature says so) and naming the post
// the reply answers
if (Value(answer, "type") == "ApproveReply")
{
if (kind != InteractionKind.Reply || Id(answer) is not { } approval || Id(answer["inReplyTo"]) is { } answers && answers != target.ObjectURI)
return true;
post.Approval = ApprovalState.Accepted;
post.ReplyApprovalURI = approval;
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.Approval, ApprovalState.Accepted).Modify(p => p.ReplyApprovalURI, approval)
.ExecuteAsync(token);
}
else
{
var authorization = Id(answer["result"]);
var interactionUri = kind == InteractionKind.Reply ? post.ObjectURI : post.ActivityURI;
if (authorization == default || !await Verified(authorization, interactionUri, target, token))
return true;
post.Approval = ApprovalState.Accepted;
post.ApprovalURI = authorization;
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.Approval, ApprovalState.Accepted).Modify(p => p.ApprovalURI, authorization)
.ExecuteAsync(token);
}
// now it goes where it was meant to, carrying the authorization
if (kind == InteractionKind.Reply)
{
@@ -202,7 +236,7 @@ namespace PrivaPub.Domain.Statuses
["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()),
["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()),
["object"] = target.ObjectURI,
["announceAuthorization"] = authorization
["announceAuthorization"] = post.ApprovalURI
};
await _delivery.EnqueueToFollowers(author, announce, token, string.IsNullOrEmpty(actor.InboxURL) ? default : new[] { actor.InboxURL });
return true;
@@ -0,0 +1,35 @@
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.User;
using System.Text.Json.Nodes;
namespace PrivaPub.Federation.Inbox.Handlers
{
// FEP-5624 (PeerTube): the author of a post whose canReply asks it to approve each reply lets one of ours in, which then
// goes to its audience carrying the approval (replyApproval)
public class ReplyApprovalHandler : IActivityHandler
{
readonly IInteractionApprovals _approvals;
public ReplyApprovalHandler(IInteractionApprovals approvals) => _approvals = approvals;
public virtual string Type => "ApproveReply";
protected virtual bool Approves => true;
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) =>
await _approvals.Answered(activity, actor, Approves, token);
}
// and refuses one, which stays ours alone
public class ReplyRejectionHandler : ReplyApprovalHandler
{
public ReplyRejectionHandler(IInteractionApprovals approvals) : base(approvals)
{
}
public override string Type => "RejectReply";
protected override bool Approves => false;
}
}
+1
View File
@@ -33,6 +33,7 @@ namespace PrivaPub.Federation.Inbox
post.ReplyPolicy = note.ReplyPolicy;
post.LikePolicy = note.LikePolicy;
post.AnnouncePolicy = note.AnnouncePolicy;
post.ReplyApprovals = note.ReplyApprovals;
post.Video = note.Video ?? post.Video;
post.Audio = note.Audio ?? post.Audio;
post.Event = note.Event ?? post.Event;
+1
View File
@@ -110,6 +110,7 @@ namespace PrivaPub.Federation.Inbox
ReplyPolicy = note.ReplyPolicy,
LikePolicy = note.LikePolicy,
AnnouncePolicy = note.AnnouncePolicy,
ReplyApprovals = note.ReplyApprovals,
Emojis = note.Emojis.ToList(),
CoverURL = note.CoverURL,
Link = note.Link,
@@ -44,6 +44,7 @@ namespace PrivaPub.Federation.Objects
public InteractionRule ReplyPolicy { get; init; }
public InteractionRule LikePolicy { get; init; }
public InteractionRule AnnouncePolicy { get; init; }
public InteractionRule ReplyApprovals { get; init; }//FEP-5624's canReply: who may reply, each reply then approved or refused
public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>();
public string CoverURL { get; init; }
public PostLink Link { get; init; }
@@ -122,6 +123,7 @@ namespace PrivaPub.Federation.Objects
ReplyPolicy = ObjectShapes.Policy(note, "canReply"),
LikePolicy = ObjectShapes.Policy(note, "canLike"),
AnnouncePolicy = ObjectShapes.Policy(note, "canAnnounce"),
ReplyApprovals = ObjectShapes.ReplyApprovals(note),
Emojis = ObjectShapes.Emojis(note["tag"]),
CoverURL = ObjectShapes.Cover(note),
Link = ObjectShapes.Link(note),
+13 -6
View File
@@ -102,16 +102,23 @@ namespace PrivaPub.Federation.Objects
// one rule of a post's interactionPolicy (GoToSocial, FEP-044f): who may, automatically or once asked; the old names
// (always, approvalRequired) too. A rule left out means anyone, automatically
static List<string> Who(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default).Take(MaxAudience).ToList(),
JsonNode single when Id(single) is { } id => new List<string> { id },
_ => new List<string>()
};
// FEP-5624's canReply (PeerTube's, on a video whose comments wait for approval): who may reply, each reply then
// approved by the author with an ApproveReply, or refused with a RejectReply; null when the post states none (PeerTube
// sends null for a video whose comments are open, and an empty list where they are closed)
public static InteractionRule ReplyApprovals(JsonObject note) =>
note["canReply"] is { } canReply ? new InteractionRule { Manual = Who(canReply) } : default;
public static InteractionRule Policy(JsonObject note, string rule)
{
if (note["interactionPolicy"] is not JsonObject policy || policy[rule] is not JsonObject allowed)
return default;
static List<string> Who(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default).Take(MaxAudience).ToList(),
JsonNode single when Id(single) is { } id => new List<string> { id },
_ => new List<string>()
};
return new InteractionRule
{
Automatic = Who(allowed["automaticApproval"] ?? allowed["always"]),
@@ -63,6 +63,7 @@ namespace PrivaPub.Federation.Rendering
["AnnounceAuthorization"] = "gts:AnnounceAuthorization",
["likeAuthorization"] = new JsonObject { ["@id"] = "gts:likeAuthorization", ["@type"] = "@id" },
["replyAuthorization"] = new JsonObject { ["@id"] = "gts:replyAuthorization", ["@type"] = "@id" },
["replyApproval"] = new JsonObject { ["@id"] = "toot:replyApproval", ["@type"] = "@id" },
["announceAuthorization"] = new JsonObject { ["@id"] = "gts:announceAuthorization", ["@type"] = "@id" },
["litepub"] = "http://litepub.social/ns#",
["EmojiReact"] = "litepub:EmojiReact",
@@ -408,6 +409,8 @@ namespace PrivaPub.Federation.Rendering
// the parent's author let this reply in (GoToSocial's interaction policies)
if (!string.IsNullOrEmpty(inReplyTo) && !string.IsNullOrEmpty(post.ApprovalURI))
note["replyAuthorization"] = post.ApprovalURI;
if (!string.IsNullOrEmpty(inReplyTo) && !string.IsNullOrEmpty(post.ReplyApprovalURI))
note["replyApproval"] = post.ReplyApprovalURI;
if (post.EditedAt.HasValue)
note["updated"] = Timestamp(post.EditedAt.Value);
return note;
@@ -80,6 +80,8 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, FollowHandler>()
.AddSingleton<IActivityHandler, AcceptHandler>()
.AddSingleton<IActivityHandler, RejectHandler>()
.AddSingleton<IActivityHandler, ReplyApprovalHandler>()
.AddSingleton<IActivityHandler, ReplyRejectionHandler>()
.AddSingleton<IActivityHandler, UndoHandler>()
.AddSingleton<IActivityHandler, LikeHandler>()
.AddSingleton<IActivityHandler, DislikeHandler>()
+9 -1
View File
@@ -82,9 +82,17 @@ namespace PrivaPub.Models.Post
public InteractionRule LikePolicy { get; set; }
[BsonIgnoreIfNull]
public InteractionRule AnnouncePolicy { get; set; }
// our reply or boost of a remote post whose policy asks its author first, and the author's authorization once given
// a remote post's FEP-5624 canReply (PeerTube): who may reply (Manual), each reply then approved by its author with an
// ApproveReply or refused with a RejectReply; null when it states none
[BsonIgnoreIfNull]
public InteractionRule ReplyApprovals { get; set; }
// our reply or boost of a remote post whose policy asks its author first, and the author's authorization once given:
// GoToSocial's (ApprovalURI, sent as replyAuthorization), or FEP-5624's ApproveReply (ReplyApprovalURI, sent as
// replyApproval)
public ApprovalState Approval { get; set; }
public string ApprovalURI { get; set; }
[BsonIgnoreIfNull]
public string ReplyApprovalURI { get; set; }
public List<string> To { get; set; } = new();
public List<string> Cc { get; set; } = new();