P6 done: personas can be quoted, under the owner's default of anyone, automatically
Build / Build (push) Successful in 1m7s
Deploy / privapub.thepra.dev (push) Successful in 1m15s

- Our public and unlisted posts state interactionPolicy.canQuote. The policy comes from the post, then the persona
  (`source[quote_policy]`: public, followers or nobody; default public as the owner chose), and is always nobody for
  followers-only posts and DMs.
- QuoteRequests are answered with Accept{result} naming a parrot-licence at /peasants/{name}/parrot-licences/{id}
  (the route name the owner chose), or with Reject. Followers-only checks that the requester really follows.
- A licence is a QuoteAuthorization naming both posts; revoking it (POST /api/v1/statuses/:id/quotes/:quoting_id/revoke)
  marks it 410, sends Delete{licence} to the quoter and the persona's followers, and revokes our own copy of the quote.
- A quote that arrives with one of our licences is accepted only if that licence is ours, unrevoked and names exactly
  that quoting post. One persona quoting another gets a licence too.
- Mastodon API: quote_approval for our posts (automatic, followers, current_user), `quote_approval_policy` when posting,
  PUT /api/v1/statuses/:id/interaction_policy, `source.quote_policy`.

Checked live: GoToSocial still accepts our posts with the policy stated, and leaves likes, replies and boosts open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 19:53:13 +02:00
1 parent f9658a8e7a
commit e6729c77e7
19 files changed
+372 -17

No files matched your search

@@ -199,6 +199,33 @@ namespace PrivaPub.Federation.Controllers
return Activity(note);
}
[HttpGet, Route("{actor}/parrot-licences/{licenceId}")]
public async Task<IActionResult> ParrotLicence(string actor, string licenceId, CancellationToken token)
{
var author = await _localActors.FindByUserName(actor, token);
var licence = author == default ? default : await DB.Default.Find<QuoteLicence>().Match(l => l.ID == licenceId && l.AuthorAvatarId == author.Id).ExecuteFirstAsync(token);
var quoted = licence == default ? default : await _dbEntities.Posts.MatchID(licence.PostId).ExecuteFirstAsync(token);
if (quoted == default)
return NotFound();
var uri = Domain.Statuses.QuoteService.LicenceUri(author, licence.ID);
if (licence.RevokedAt.HasValue || quoted.DeletedAt.HasValue)
return new ContentResult
{
Content = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = uri, ["type"] = "Tombstone", ["formerType"] = "QuoteAuthorization" }.ToJsonString(),
ContentType = ActivityContentType,
StatusCode = StatusCodes.Status410Gone
};
return Activity(new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = uri,
["type"] = "QuoteAuthorization",
["attributedTo"] = author.Uri,
["interactingObject"] = licence.QuotingObjectURI,
["interactionTarget"] = quoted.ObjectURI
});
}
[HttpGet, Route("{actor}/grunts/{activityId}")]
public async Task<IActionResult> Grunt(string actor, string activityId, CancellationToken token)
{