P7: FEP-268d searchableBy decides what search finds
Build / Build (push) Successful in 7m58s

A remote account's searchableBy, and a post's own, outrank indexable in status search: Public lets anyone find a
public post, the author's followers only those who follow it, anything else nobody but those it already reaches
(their own, named, favourited, bookmarked or boosted posts). Read on actors and posts, kept through edits; PrivaPub
does not emit it.

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-07 21:17:36 +02:00
1 parent d4b1788a4f
commit fb4949b511
11 files changed
+111 -5

No files matched your search

@@ -1,6 +1,12 @@
using MongoDB.Entities;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// Status search by words, as Mastodon scopes it: the persona's own posts and those it boosted, favourited, bookmarked
@@ -47,6 +53,67 @@ namespace PrivaPub.Tests.Http
(await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=1&offset=1")).Ok().Body["statuses"]!.AsArray().Select(s => s.Text("id")));
}
// FEP-268d: a remote account's searchableBy outranks its indexable, and a post's own outranks its author's
[Fact]
public async Task A_remote_searchable_by_decides_before_indexable()
{
var token = TestContext.Current.CancellationToken;
await using var peer = await Peer.Start();
var reader = await _host.Mastodon("reader");
var keeper = await _host.Mastodon("keeper");
var word = $"s{Guid.NewGuid():N}"[..12];
async Task<RemoteActor> Author(string name, bool indexable, Func<RemoteActor, JsonArray> searchableBy)
{
var author = new RemoteActor(peer, name);
var document = author.Document();
document["indexable"] = indexable;
if (searchableBy != default)
document["searchableBy"] = searchableBy(author);
peer.Serve($"/users/{author.Name}", document.ToJsonString());
await DB.Default.SaveAsync(new Following { AvatarId = keeper.Id, TargetActorURI = author.Id, State = FollowState.Accepted }, token);
return author;
}
async Task<string> Says(RemoteActor author, string text, JsonArray searchableBy = default)
{
var noteId = $"{peer.A}/notes/{Guid.NewGuid():N}";
var note = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(author.Id + "/followers"), ["published"] = DateTime.UtcNow.ToString("O")
};
if (searchableBy != default)
note["searchableBy"] = searchableBy;
using var client = _host.Client();
await client.SendAsync(author.SignedPost("/human-centipede", new JsonObject
{
["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = author.Id,
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(author.Id + "/followers"), ["object"] = note
}), token);
Assert.Equal(1, await _host.RunInbox(noteId + "/activity", token));
return (await DB.Default.Find<Models.Post.Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token)).ID;
}
var open = await Says(await Author("open", indexable: false, _ => new JsonArray(Addressing.Public)), $"open {word}");
var closed = await Says(await Author("closed", indexable: true, _ => new JsonArray()), $"closed {word}");
var friendly = await Author("friendly", indexable: false, a => new JsonArray(a.Id + "/followers"));
var friends = await Says(friendly, $"friends {word}");
var quiet = await Author("quiet", indexable: false, default);
var perPost = await Says(quiet, $"per post {word}", new JsonArray(Addressing.Public));
var plain = await Says(quiet, $"plain {word}");
static List<string> Found(JsonNode body) => body["statuses"]!.AsArray().Select(s => s.Text("id")).ToList();
var before = Found((await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=40")).Ok().Body);
Assert.Contains(open, before);
Assert.Contains(perPost, before);
Assert.DoesNotContain(closed, before);
Assert.DoesNotContain(friends, before);
Assert.DoesNotContain(plain, before);
await DB.Default.SaveAsync(new Following { AvatarId = reader.Id, TargetActorURI = friendly.Id, State = FollowState.Accepted }, token);
Assert.Contains(friends, Found((await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=40")).Ok().Body));
}
// a handle asks for its account: no posts sharing its words, and no hashtag made of it
[Fact]
public async Task A_handle_finds_its_account_and_neither_posts_nor_a_hashtag()
@@ -105,15 +105,30 @@ namespace PrivaPub.Api.Mastodon.Controllers
mine.UnionWith((await _dbEntities.Posts.Match(p => p.AuthorAccountId == MyId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
.Select(p => p.ReblogOfPostId));
var authors = candidates.Select(p => p.AuthorAccountId ?? p.GroupUserId).Where(a => a != default).Distinct().ToList();
var indexable = (await _dbEntities.Avatars.Match(a => authors.Contains(a.ID) && a.Settings.IsIndexable).ExecuteAsync(token)).Select(a => a.ID)
.Concat((await _dbEntities.ForeignAvatars.Match(f => authors.Contains(f.ID) && f.IsIndexable).ExecuteAsync(token)).Select(f => f.ID))
.ToHashSet();
var indexable = (await _dbEntities.Avatars.Match(a => authors.Contains(a.ID) && a.Settings.IsIndexable).ExecuteAsync(token)).Select(a => a.ID).ToHashSet();
// a remote author's searchableBy (FEP-268d) outranks its indexable, and a post's own outranks both
var remoteAuthors = (await _dbEntities.ForeignAvatars.Match(f => authors.Contains(f.ID)).ExecuteAsync(token)).ToDictionary(f => f.ID);
var stating = remoteAuthors.Values.Where(f => f.SearchableBy != default).Select(f => f.ActorURI)
.Concat(candidates.Where(p => p.SearchableBy != default).Select(p => p.ActorURI))
.Where(uri => uri != default).Distinct().ToList();
var followed = stating.Count == 0
? new HashSet<string>()
: (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && stating.Contains(f.TargetActorURI) && f.State == Models.Social.FollowState.Accepted)
.ExecuteAsync(token)).Select(f => f.TargetActorURI).ToHashSet();
bool Searchable(PostEntity post, string author)
{
var remote = remoteAuthors.GetValueOrDefault(author);
var rule = post.SearchableBy ?? remote?.SearchableBy;
if (rule != default)
return Federation.Objects.ObjectShapes.Searchable(rule, remote?.FollowersURL, followed.Contains(post.ActorURI ?? string.Empty));
return remote?.IsIndexable == true || indexable.Contains(author);
}
var found = new List<PostEntity>();
foreach (var post in candidates)
{
var author = post.AuthorAccountId ?? post.GroupUserId;
var reachable = author == MyId && !post.IsFederatedCopy || mine.Contains(post.ID) || post.Mentions.Any(m => m.AccountId == MyId)
|| post.Visibility == PostVisibility.Public && indexable.Contains(author);
|| post.Visibility == PostVisibility.Public && Searchable(post, author);
if (reachable && await VisibilityPolicy.CanSee(post, MyId, token))
found.Add(post);
if (found.Count >= offset + limit)
@@ -38,6 +38,7 @@ namespace PrivaPub.Federation.Actors
public string MovedTo { get; init; }
public string Moderators { get; init; }
public List<PostFlair> Flairs { get; init; } = new();
public List<string> SearchableBy { get; init; }
public List<string> AlsoKnownAs { get; init; } = new();//the accounts it says it also is (a Move's target names the moved one)
public DateTime? Published { get; init; }
public List<CustomEmoji> Emojis { get; init; } = new();
@@ -96,6 +97,7 @@ namespace PrivaPub.Federation.Actors
MovedTo = RemoteActorService.Text(root, "movedTo"),
Flairs = type != "Group" ? new() : ObjectShapes.Flairs(root.TryGetProperty("tag", out var labels) ? JsonNode.Parse(labels.GetRawText()) : default,
root.TryGetProperty("lemmy:tagsForPosts", out var older) ? JsonNode.Parse(older.GetRawText()) : default),
SearchableBy = root.TryGetProperty("searchableBy", out var searchable) ? ObjectShapes.SearchableBy(JsonNode.Parse(searchable.GetRawText())) : default,
Moderators = type == "Group" ? RemoteActorService.Text(root, "attributedTo") ?? RemoteActorService.Text(root, "moderators") : default,
AlsoKnownAs = root.TryGetProperty("alsoKnownAs", out var aliases) ? Uris(aliases) : new(),
Published = DateTimeOffset.TryParse(RemoteActorService.Text(root, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published)
@@ -236,6 +236,7 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.MovedToURL, Origin.Of(actor.MovedTo) == default ? default : actor.MovedTo)
.Modify(a => a.ModeratorsURL, Origin.Same(actor.Moderators, actor.Id) ? actor.Moderators : default)
.Modify(a => a.Flairs, actor.Flairs)
.Modify(a => a.SearchableBy, actor.SearchableBy)
.Modify(a => a.AlsoKnownAs, actor.AlsoKnownAs)
.Modify(a => a.Published, actor.Published)
.Modify(a => a.Emojis, actor.Emojis)
+1
View File
@@ -39,6 +39,7 @@ namespace PrivaPub.Federation.Inbox
post.Event = note.Event ?? post.Event;
post.Place = note.Place;
post.Flairs = await RemotePosts.Flairs(note, post.AudienceURI, token);
post.SearchableBy = note.SearchableBy;
if (note.CommentsEnabled is { } enabled)
post.LockedAt = enabled ? null : post.LockedAt ?? DateTime.UtcNow;
if (!IsEdit(note, post))
+1
View File
@@ -106,6 +106,7 @@ namespace PrivaPub.Federation.Inbox
Excerpt = note.Excerpt,
Source = note.Source,
Poll = note.Poll,
SearchableBy = note.SearchableBy,
QuotePolicy = note.QuotePolicy,
ReplyPolicy = note.ReplyPolicy,
LikePolicy = note.LikePolicy,
@@ -41,6 +41,7 @@ namespace PrivaPub.Federation.Objects
public PostSource Source { get; init; }
public PostPoll Poll { get; init; }
public List<PostFlair> Flairs { get; init; } = new();
public List<string> SearchableBy { get; init; }
public InteractionRule QuotePolicy { get; init; }
public InteractionRule ReplyPolicy { get; init; }
public InteractionRule LikePolicy { get; init; }
@@ -121,6 +122,7 @@ namespace PrivaPub.Federation.Objects
Source = ObjectShapes.Source(note),
Poll = ObjectShapes.Poll(note),
Flairs = ObjectShapes.Flairs(note["tag"]),
SearchableBy = ObjectShapes.SearchableBy(note["searchableBy"]),
QuotePolicy = ObjectShapes.QuotePolicy(note),
ReplyPolicy = ObjectShapes.Policy(note, "canReply"),
LikePolicy = ObjectShapes.Policy(note, "canLike"),
@@ -100,6 +100,18 @@ namespace PrivaPub.Federation.Objects
const int MaxFlairs = 20;
// FEP-268d: who may find this in a search (Public, the author's followers, nobody else); null when not stated
public static List<string> SearchableBy(JsonNode node) => node switch
{
null => default,
JsonArray array => array.Select(Id).Where(id => id != default).Take(MaxAudience).ToList(),
_ when Id(node) is { } single => new List<string> { single },
_ => new List<string>()
};
public static bool Searchable(List<string> searchableBy, string followers, bool viewerFollows) =>
searchableBy.Any(Addressing.IsPublic) || viewerFollows && searchableBy.Any(w => w == followers || w.EndsWith("/followers", StringComparison.Ordinal));
// a post's flairs, or a community's (its `tag`, and PieFed's older `lemmy:tagsForPosts`), in both dialects
public static List<PostFlair> Flairs(params JsonNode[] lists) =>
lists.SelectMany(Objects)
+2
View File
@@ -48,6 +48,8 @@ namespace PrivaPub.Models.Post
public PostPoll Poll { get; set; }
[BsonIgnoreIfNull]
public List<PostFlair> Flairs { get; set; }
[BsonIgnoreIfNull]
public List<string> SearchableBy { get; set; }//FEP-268d on the post itself: outranks its author's
public List<float> Location { get; set; } = new();
public float RangeKm { get; set; } = 5.0f;
[BsonIgnoreIfNull]
+1
View File
@@ -78,6 +78,7 @@ namespace PrivaPub.Models.User
public string OutboxURL { get; set; }
public string FeaturedURL { get; set; }//featured: the posts it pins
public List<PrivaPub.Models.Post.PostFlair> Flairs { get; set; } = new();//a community's labels for its posts
public List<string> SearchableBy { get; set; }//FEP-268d, when the account states it: outranks IsIndexable
public string ModeratorsURL { get; set; }//a community's attributedTo (Lemmy, PieFed, Mbin): the collection of its moderators
public List<AssertionKey> AssertionKeys { get; set; } = new();//its Ed25519 keys (FEP-521a), which prove what it sends (FEP-8b32)
public string WallURL { get; set; }//sm:wall (FEP-400e): where others write to it, Smithereen's walls
+3 -1
View File
@@ -712,7 +712,9 @@ it, raw where it doesn't.
- FEP-8fcf followers sync: **done 2026-10-06** (owner decision; sent: the digest of a persona's followers on the
receiving server, and a signed roll-call listing only them; honoured: a sender's digest of its followers here,
mended from its list; checked live against Mastodon, all four ways a follow can be lost);
- `indexable`/`discoverable`/`searchableBy`;
- `indexable`/`discoverable`/`searchableBy`: **done 2026-10-07** (account search already left out undiscoverable
accounts; status search now reads FEP-268d `searchableBy` on remote accounts and on each post, which outranks
`indexable`: Public, the author's followers (the reader must follow), or nobody else; not emitted by us);
- edit history from `formerRepresentations`: **done 2026-10-06** (Pleroma's and Akkoma's earlier versions, for a post
met after its edits and for the edits missed in between);
- PeerTube reply rules and `ApproveReply`: **done 2026-10-06** (FEP-5624's `canReply`: a reply waits for the author's