Personas have walls their followers write on

Owner decision of 2026-10-06 (G-0009, FEP-400e). A persona's actor names its wall (…/graffiti, sm:wall) and, in
Smithereen's privacySettings, that its followers may write on it. A public post that is not a reply, sent with the wall
as its target by an account following the persona, is hosted: the persona is notified, it reaches the persona's and its
followers' homes, and the followers' servers and the author's are told with Add{Note}. The persona deletes it with
DELETE /api/v1/statuses/:id, which sends Remove{Note}; Smithereen deletes the post then. The wall lists the persona's
public posts that start a thread and what was written on it.

Elsewhere: an account's Add{Note} on its own wall shows the post to its followers here as on that wall (privapub.wall on
the status), and its Remove takes it away. An Add or Remove naming a collection of the account's own server PrivaPub
does not know has its document read again first, at most hourly: accounts kept before walls were read had none.

Checked live: Smithereen 40 checks (G-0009 closed); GoToSocial and Mastodon unchanged (121).

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 08:17:10 +02:00
1 parent e0682fa868
commit c47b6e5533
26 files changed
+582 -36

No files matched your search

+3 -2
View File
@@ -608,8 +608,9 @@ tools/pasture/run.sh down # removes e
`log_bin_trust_function_creators`, and a first start that failed there leaves the database without triggers (drop it
and start again). Accounts come from its signup form (opened, with a short description, which its NodeInfo needs) and
are renamed in its database; its API is VKontakte's (`/api/method/<name>?v=1.0`), with a password grant for a local
application the peer script inserts. `scenarios/smithereen.sh`, 30 checks and one known gap (G-0009, posts on
someone else's wall).
application the peer script inserts. `scenarios/smithereen.sh`, 40 checks, walls both ways (alice's profile is
told again first, so Smithereen reads her `sm:wall`; `servers.features & 1` says it took PrivaPub for a server with
walls). Its scenario texts avoid apostrophes: `p_home_has` puts them inside a Python string.
- **Load (`load.sh`, needs the `flood` peer):** `flood` (`flood/flood.cs`, published once into `.flood`) answers as
twenty fake servers and sends signed activities at a set rate; `load.sh --rate=N --seconds=N` measures the answers,
the queue's wait and processing times, its drain, and a persona's home timeline meanwhile, and keeps each run in
+13 -1
View File
@@ -94,6 +94,8 @@ actor document, a collection, NodeInfo or a delivery relates two avatars of the
| Activities | `/peasants/{name}/grunts/{id}` |
| Quote permissions (`QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
| Direct-message context | `/peasants/{name}/whispers/{id}` |
| A persona's wall (FEP-400e) | `/peasants/{name}/graffiti` |
| Followers on the asking server (FEP-8fcf) | `/peasants/{name}/groupies/roll-call` |
| Profile and post pages | `/@{name}`, `/@{name}/{id}` |
The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path.
@@ -159,7 +161,7 @@ Received:
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
| `Flag` | becomes a report for this server's moderators |
| `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`. The old account then shows where it went (`moved`), and, as Mastodon does it (owner decision 2026-10-05), the personas following it follow the new one instead (a Follow to its server, an Undo to the old), in the same lists; a mute or a block of the old account carries over |
| `Add`/`Remove` on the actor's `featured` | the account pins or unpins one of its own posts (fetched from its server when not held); inside a community's announce, the community features a post made in it. Its profile shows them first (`pinned=true`). The collection itself is read with the account's counts, at most once a day. Any other target (Smithereen's wall, a community's moderators) is dropped |
| `Add`/`Remove` on the actor's `featured` | the account pins or unpins one of its own posts (fetched from its server when not held); inside a community's announce, the community features a post made in it. Its profile shows them first (`pinned=true`). The collection itself is read with the account's counts, at most once a day. On the account's wall (`sm:wall`), see "Walls". Any other target (a community's moderators) is dropped; one on the account's own server that PrivaPub does not know has its document read again first, at most hourly |
| `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
@@ -183,6 +185,16 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T
following it, PrivaPub reads the list it names (same origin, signed by the instance actor). A follow the list leaves out
ends, but only when the list is the one the digest describes; a request it lists is taken as accepted; a persona it
lists that follows nothing there sends `Undo{Follow}`, as Mastodon does.
- **Walls** (FEP-400e, Smithereen's; owner decision 2026-10-06). A persona's actor names its `wall`
(`…/graffiti`, `sm:wall`) and Smithereen's `privacySettings`: its followers may write on it (`wallPosting`), everyone
may read it (`wallPostVisibility`). A public post that is not a reply, sent with the wall as its `target` by an account
following the persona, is hosted: the persona is notified (as a mention), it reaches the persona's home and its
followers' here, and the persona's followers' servers and the author's are told with `Add{Note}` (the wall as the
target). Anything else written there is dropped. The persona may delete it (`DELETE /api/v1/statuses/:id`), which
sends `Remove{Note}` with the wall as a plain id; Smithereen deletes the post then, as it does when an owner deletes a
post on its wall. The wall lists the persona's public posts that start a thread and what was written on it, newest
first. An account elsewhere that tells its followers here, with `Add{Note}` on its own wall, of a post written there
has it shown to them as on its wall (`privapub.wall` on the status); a `Remove` there takes it away.
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`. A pin or an unpin
is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it.
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
+213
View File
@@ -0,0 +1,213 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.Host.FederationHelpers;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Tests.Federation
{
// walls (FEP-400e, Smithereen's; owner decision 2026-10-06): a persona's followers write on its wall, PrivaPub hosts what
// they write and tells the persona's followers with Add{Note}; the persona takes it off with Remove; an account elsewhere
// that tells of a post on its own wall has it shown to its followers here
[Trait("Category", "Integration")]
public sealed class WallTests : IAsyncLifetime
{
const string Public = "https://www.w3.org/ns/activitystreams#Public";
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static CancellationToken Token => TestContext.Current.CancellationToken;
// a public note by author, written on the wall of owner (whose actor and wall are given), served at its id
string WallNote(RemoteActor author, string ownerUri, string wall, string text, string inReplyTo = default, string to = Public)
{
var path = $"/notes/{Guid.NewGuid():N}";
var origin = new Uri(author.Id).GetLeftPart(UriPartial.Authority);
var note = new JsonObject
{
["id"] = origin + path, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
["to"] = new JsonArray(to, ownerUri), ["published"] = DateTime.UtcNow.ToString("O"),
["target"] = new JsonObject { ["type"] = "Collection", ["id"] = wall, ["attributedTo"] = ownerUri }
};
if (inReplyTo != default)
note["inReplyTo"] = inReplyTo;
_harness.Peer.Serve(path, note.ToJsonString());
_notes[origin + path] = note;
return origin + path;
}
readonly Dictionary<string, JsonObject> _notes = new();
// as Smithereen sends it: the whole note, to the wall's owner
JsonObject Create(RemoteActor author, string noteId) => new()
{
["id"] = noteId + "/activityCreate", ["type"] = "Create", ["actor"] = author.Id, ["to"] = _notes[noteId]["to"]!.DeepClone(),
["object"] = _notes[noteId].DeepClone()
};
Task<PostEntity> Post(string objectUri) => DB.Default.Find<PostEntity>().Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(Token);
[Fact]
public void A_persona_names_its_wall_and_lets_its_followers_write_on_it()
{
var actor = ActivityPubRenderer.Actor(new LocalActor { Id = "a", UserName = "alice", BaseAddress = Harness.Base, Kind = PrivaPub.Models.Federation.LocalActorKind.Person });
var circle = ActivityPubRenderer.Actor(new LocalActor { Id = "c", UserName = "circle", BaseAddress = Harness.Base, Kind = PrivaPub.Models.Federation.LocalActorKind.Group, IsCircle = true });
Assert.Equal($"{Harness.Base}/peasants/alice/graffiti", actor["wall"]!.GetValue<string>());
Assert.Equal($"{Harness.Base}/peasants/alice/groupies", actor["privacySettings"]!["wallPosting"]!["allowedTo"]![0]!.GetValue<string>());
Assert.Equal(Public, actor["privacySettings"]!["wallPostVisibility"]!["allowedTo"]![0]!.GetValue<string>());
Assert.Contains(actor["@context"]!.AsArray(), c => c is JsonObject terms && terms["wall"]?["@id"]?.GetValue<string>() == "sm:wall");
Assert.Null(circle["wall"]);
}
[Fact]
public async Task A_followers_post_on_the_wall_is_hosted_shown_and_told_to_the_personas_followers()
{
var (_, alice) = await _harness.Persona("alice");
var smuser = new RemoteActor(_harness.Peer, "smuser", wall: true);
await _harness.FollowedBy(alice, smuser);
var noteId = WallNote(smuser, alice.Uri, alice.Wall, "on alice's wall");
await _harness.Deliver(smuser, "/human-centipede", Create(smuser, noteId));
var post = await Post(noteId);
Assert.NotNull(post);
Assert.Equal((alice.Wall, alice.Uri, alice.Id), (post.WallURI, post.WallOwnerURI, post.WallOwnerAccountId));
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token));
Assert.True(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.PostId == post.ID && n.Type == NotificationType.Mention).ExecuteAnyAsync(Token));
var add = Assert.Single(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue<string>() == "Add");
Assert.Equal((alice.Uri, noteId, alice.Wall), (add["actor"]!.GetValue<string>(), add["object"]!.GetValue<string>(), add["target"]!["id"]!.GetValue<string>()));
Assert.Contains(await _harness.Outgoing(smuser.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Add");
Assert.True(await _harness.Walls.Remove(alice, post, Token));
Assert.Null(await Post(noteId));
var remove = Assert.Single(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue<string>() == "Remove");
Assert.Equal((noteId, alice.Wall), (remove["object"]!.GetValue<string>(), remove["target"]!.GetValue<string>()));
}
[Fact]
public async Task A_wall_post_from_a_stranger_a_reply_or_a_quiet_one_is_refused()
{
var (_, alice) = await _harness.Persona("alice");
var stranger = new RemoteActor(_harness.Peer, "stranger", wall: true);
var follower = new RemoteActor(_harness.Peer, "follower", wall: true);
await _harness.FollowedBy(alice, follower);
var fromStranger = WallNote(stranger, alice.Uri, alice.Wall, "from a stranger");
var reply = WallNote(follower, alice.Uri, alice.Wall, "a reply", inReplyTo: "https://elsewhere.example/notes/1");
var quiet = WallNote(follower, alice.Uri, alice.Wall, "quietly", to: follower.Id + "/followers");
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, fromStranger));
await _harness.Deliver(follower, "/human-centipede", Create(follower, reply));
await _harness.Deliver(follower, "/human-centipede", Create(follower, quiet));
Assert.Null(await Post(fromStranger));
Assert.Null(await Post(reply));
Assert.Null(await Post(quiet));
Assert.DoesNotContain(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue<string>() == "Add");
}
[Fact]
public async Task A_post_on_a_followed_accounts_wall_elsewhere_reaches_its_followers_here_until_it_is_taken_off()
{
var (_, alice) = await _harness.Persona("alice");
var owner = new RemoteActor(_harness.Peer, "owner", wall: true);
var writer = new RemoteActor(_harness.Peer, "writer");
await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = owner.Id, TargetInboxURL = owner.Id + "/inbox", State = FollowState.Accepted }, Token);
var noteId = WallNote(writer, owner.Id, owner.Wall, "on the owner's wall");
// (kept before PrivaPub read walls: its document is read again when it names one)
await DB.Default.SaveAsync(new PrivaPub.Models.User.ForeignAvatar
{
ActorURI = owner.Id, UserName = owner.Name, PublicKeyId = owner.KeyId, PublicKey = owner.PublicKeyPem, InboxURL = owner.Id + "/inbox",
FollowersURL = owner.Id + "/followers", UpdatedAt = DateTime.UtcNow.AddDays(-2)
}, Token);
await _harness.Deliver(owner, "/human-centipede", new JsonObject
{
["id"] = $"{owner.Id}/posts/{Guid.NewGuid():N}/activityAdd", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = noteId,
["target"] = new JsonObject { ["type"] = "Collection", ["id"] = owner.Wall, ["attributedTo"] = owner.Id },
["to"] = new JsonArray(Public, owner.Id + "/followers")
});
var post = await Post(noteId);
Assert.NotNull(post);
Assert.Equal((writer.Id, owner.Id), (post.ActorURI, post.WallOwnerURI));
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token));
Assert.False(await DB.Default.Find<RemoteFeatured>().Match(f => f.ObjectURI == noteId).ExecuteAnyAsync(Token));
await _harness.Deliver(owner, "/human-centipede", new JsonObject
{
["id"] = $"{owner.Id}/posts/{Guid.NewGuid():N}/activityRemove", ["type"] = "Remove", ["actor"] = owner.Id, ["object"] = noteId, ["target"] = owner.Wall
});
Assert.Null(await Post(noteId));
}
}
[Trait("Category", "Integration")]
public sealed class WallCollectionTests : IAsyncLifetime
{
PrivaPubHost _host;
HttpClient _client;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_client = _host.Client();
}
public ValueTask DisposeAsync()
{
_client?.Dispose();
return ValueTask.CompletedTask;
}
[Fact]
public async Task The_wall_lists_the_personas_public_posts_and_what_others_wrote_on_it()
{
var token = TestContext.Current.CancellationToken;
var persona = await _host.Persona(await _host.SignUp(), "wall");
var open = await _host.Publish(persona, "for everyone", PostVisibility.Public);
var quiet = await _host.Publish(persona, "for followers", PostVisibility.FollowersOnly);
var wall = persona.ActorUri() + "/graffiti";
var written = new PostEntity
{
ObjectURI = $"https://elsewhere.example/notes/{Guid.NewGuid():N}", ActorURI = "https://elsewhere.example/users/writer", IsFederatedCopy = true,
Visibility = PostVisibility.Public, WallURI = wall, WallOwnerURI = persona.ActorUri(), WallOwnerAccountId = persona.Id
};
await DB.Default.SaveAsync(written, token);
var collection = await _client.Fetch($"/peasants/{persona.UserName}/graffiti");
var page = await _client.Fetch($"/peasants/{persona.UserName}/graffiti?page=true");
Assert.Equal(HttpStatusCode.OK, collection.Status);
Assert.Equal((wall, 2), (collection.Json["id"]!.GetValue<string>(), collection.Json["totalItems"]!.GetValue<int>()));
var items = page.Json["orderedItems"]!.AsArray().Select(i => i!.GetValue<string>()).ToList();
Assert.Equal(new[] { written.ObjectURI, persona.ActorUri() + "/scribbles/" + open.ID }, items);
Assert.DoesNotContain(items, i => i.EndsWith(quiet.ID));
}
}
}
+5 -3
View File
@@ -59,6 +59,7 @@ namespace PrivaPub.Tests.Support
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Relationships = new RelationshipService(Db, Follows, Delivery);
Featured = new FeaturedPosts(Db, Remote, RemotePosts);
Walls = new Walls(Db, Local, RemotePosts, Delivery, Fanout);
Handlers = new IActivityHandler[]
{
new FollowHandler(Db, Local, Remote, Delivery),
@@ -71,15 +72,15 @@ namespace PrivaPub.Tests.Support
new DislikeHandler(Db),
new JoinHandler(Db, Local, Delivery),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes, relays: Relays),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes, Approvals, Relays),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes, Approvals, Relays, Walls),
new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes, Delivery),
new FlagHandler(Db, Local),
new BlockHandler(Db, Local),
new LockHandler(Db),
new MoveHandler(Remote, Db, Local, Follows, Relationships),
new FeaturedHandler(Featured, add: true),
new FeaturedHandler(Featured, add: false)
new FeaturedHandler(Featured, add: true, Walls, Remote),
new FeaturedHandler(Featured, add: false, Walls, Remote)
};
((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers;
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local);
@@ -120,6 +121,7 @@ namespace PrivaPub.Tests.Support
public TimelineService Timelines { get; }
public RelationshipService Relationships { get; }
public FeaturedPosts Featured { get; }
public Walls Walls { get; }
public Relays Relays { get; }
// the relays the harness's instance actor subscribes to: a test sets them before Relays.Reconcile
public PrivaPub.Infrastructure.Http.FederationOptions RelayOptions { get; } = new();
+8 -2
View File
@@ -14,14 +14,17 @@ namespace PrivaPub.Tests.Support
{
readonly RSA _key = RSA.Create(2048);
readonly bool _namesSharedInbox;
readonly bool _hasWall;
// sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do
public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false)
// sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do; wall:
// whether it has a wall (sm:wall, Smithereen's)
public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false, bool wall = false)
{
Name = $"{name}{Guid.NewGuid():N}"[..20];
Id = $"{origin ?? peer.A}/users/{Name}";
Type = type;
_namesSharedInbox = sharedInbox;
_hasWall = wall;
peer.Serve($"/users/{Name}", Document().ToJsonString());
}
@@ -31,6 +34,7 @@ namespace PrivaPub.Tests.Support
public string Id { get; }
public string KeyId => Id + "#main-key";
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
public string Wall => Id + "/wall";
public JsonObject Document()
{
@@ -52,6 +56,8 @@ namespace PrivaPub.Tests.Support
};
if (_namesSharedInbox)
document["endpoints"] = new JsonObject { ["sharedInbox"] = SharedInbox };
if (_hasWall)
document["wall"] = Wall;
return document;
}
@@ -34,10 +34,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
readonly Domain.Media.IMediaService _media;
readonly IParticipations _participations;
readonly Federation.Actors.IWalls _walls;
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes,
IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default)
IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default,
Federation.Actors.IWalls walls = default)
{
_walls = walls;
_participations = participations;
_jobs = jobs;
_media = media;
@@ -161,7 +164,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
{
var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (before == default)
return NotFoundError();
return await OffWall(id, token);
var status = await _mapper.Status(before, MyId, token);
var outcome = await _statuses.Remove(Me, id, token);
if (!outcome.Ok)
@@ -174,6 +177,20 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(status);
}
// someone's post on the persona's wall (FEP-400e), taken off it: as on Smithereen, the owner of a wall deletes what is on it
async Task<IActionResult> OffWall(string id, CancellationToken token)
{
var walled = _walls == default
? default
: await _dbEntities.Posts.Match(p => p.ID == id && p.WallOwnerAccountId == MyId && p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (walled == default)
return NotFoundError();
var status = await _mapper.Status(walled, MyId, token);
if (!await _walls.Remove(Me, walled, token))
return NotFoundError();
return Json(status);
}
[HttpGet("/api/v1/statuses/{id}/context"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Context(string id, CancellationToken token)
{
@@ -77,6 +77,7 @@
public PrivaPubAudio Audio { get; set; }
public PrivaPubEvent Event { get; set; }
public PrivaPubPlace Place { get; set; }//a remote post's own place (Pixelfed), shown only
public Account Wall { get; set; }//whose wall it was written on (FEP-400e), when not its author's
public PrivaPubVotes Votes { get; set; } = new();
}
@@ -203,7 +203,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
var mentionAccounts = mentionUris.Count == 0
? new Dictionary<string, ForeignAvatar>()
: (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI);
var accounts = await Accounts(all.Select(AuthorOf), token);
var accounts = await Accounts(all.Select(AuthorOf).Concat(all.Select(p => p.WallOwnerAccountId).Where(w => w != default)), token);
var ids = all.Select(p => p.ID).ToList();
var favourited = viewerId == default
@@ -277,7 +277,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
Card = Card(post),
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
Emojis = Emojis(post.Emojis),
Privapub = Extension(post),
Privapub = Extension(post, accounts),
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
Mentions = post.Mentions.Where(m => !m.Silent).Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
@@ -480,8 +480,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new();
PrivaPubStatus Extension(PostEntity post) => new()
PrivaPubStatus Extension(PostEntity post, Dictionary<string, Account> accounts) => new()
{
Wall = post.WallOwnerAccountId == default || post.WallOwnerAccountId == AuthorOf(post) ? default : accounts.GetValueOrDefault(post.WallOwnerAccountId),
ObjectType = post.ObjectType,
Locked = post.LockedAt.HasValue,
Approval = post.Approval is ApprovalState.Pending or ApprovalState.Rejected ? post.Approval.ToString().ToLowerInvariant() : default,
+8
View File
@@ -43,6 +43,14 @@ namespace PrivaPub.Domain.Timelines
if (!string.IsNullOrEmpty(post.AudienceURI))
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted).ExecuteAsync(token))
recipients.Add(member.AvatarId);
// a post on a wall (FEP-400e): its owner, when a persona, and those following the owner
if (!string.IsNullOrEmpty(post.WallOwnerURI))
{
if (await _dbEntities.Avatars.MatchID(post.WallOwnerAccountId).ExecuteAnyAsync(token))
recipients.Add(post.WallOwnerAccountId);
foreach (var follower in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.WallOwnerURI && f.State == FollowState.Accepted).ExecuteAsync(token))
recipients.Add(follower.AvatarId);
}
// a public post (not a boost, not a reply) comes to those following one of its hashtags, as on Mastodon
if (post.Visibility == PostVisibility.Public && post.Tags.Count > 0 && string.IsNullOrEmpty(post.ReblogOfPostId)
&& string.IsNullOrEmpty(post.InReplyToURI) && string.IsNullOrEmpty(post.AnsweringToPostId))
@@ -24,6 +24,7 @@ namespace PrivaPub.Federation.Actors
public string Followers { get; init; }
public string Following { get; init; }
public string Featured { get; init; }
public string Wall { get; init; }
public string SharedInbox { get; init; }
public string Icon { get; init; }
public bool Discoverable { get; init; } = true;
@@ -75,6 +76,7 @@ namespace PrivaPub.Federation.Actors
Followers = RemoteActorService.Text(root, "followers"),
Following = RemoteActorService.Text(root, "following"),
Featured = RemoteActorService.Text(root, "featured"),
Wall = RemoteActorService.Text(root, "wall") ?? RemoteActorService.Text(root, "sm:wall"),
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
@@ -46,6 +46,8 @@ namespace PrivaPub.Federation.Actors
public string Following => $"{Uri}/stalking";
public string Featured => $"{Uri}/trophies";
public string FeaturedTags => $"{Uri}/tattoos";
public string Wall => $"{Uri}/graffiti";
public bool HasWall => Kind == LocalActorKind.Person && !IsCircle && IsFederated;
public string Flock => $"{Uri}/flock";
public string Wardens => $"{Uri}/wardens";
public string SharedInbox => $"{BaseAddress}/human-centipede";
@@ -205,6 +205,7 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.FollowersURL, actor.Followers)
.Modify(a => a.FollowingURL, actor.Following)
.Modify(a => a.FeaturedURL, Origin.Same(actor.Featured, actor.Id) ? actor.Featured : default)
.Modify(a => a.WallURL, Origin.Same(actor.Wall, actor.Id) ? actor.Wall : default)
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
.Modify(a => a.PictureURL, actor.Icon)
.Modify(a => a.ThumbnailURL, actor.Header)
+157
View File
@@ -0,0 +1,157 @@
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Actors
{
public interface IWalls
{
Task<LocalActor> OwnerOf(string wallUri, CancellationToken token);
Task<bool> MayWrite(LocalActor owner, ForeignAvatar author, CancellationToken token);
Task Hosted(PostEntity post, LocalActor owner, CancellationToken token);
Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token);
Task<bool> Remove(LocalActor owner, PostEntity post, CancellationToken token);
}
// Walls (FEP-400e, Smithereen's; owner decision 2026-10-06). A persona's actor names its wall (`…/graffiti`), on which
// the accounts following it may write: their public post, sent with the wall as its `target`, is hosted here, shown to
// the persona and its followers, and told to its followers' servers with Add{Note}. The persona may take it off again
// (Remove, which Smithereen takes for a deletion, as it is on its walls). Another server's account that tells its
// followers here of a post on its wall (Add) has it shown to them as on its wall.
public class Walls : IWalls
{
public const string Path = "graffiti";
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemotePosts _remotePosts;
readonly IDeliveryService _delivery;
readonly IFanout _fanout;
public Walls(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IDeliveryService delivery, IFanout fanout)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remotePosts = remotePosts;
_delivery = delivery;
_fanout = fanout;
}
public async Task<LocalActor> OwnerOf(string wallUri, CancellationToken token)
{
if (string.IsNullOrEmpty(wallUri) || !wallUri.EndsWith("/" + Path, StringComparison.Ordinal))
return default;
var owner = await _localActors.FindByUri(wallUri[..^(Path.Length + 1)], token);
return owner is { HasWall: true } && owner.Wall == wallUri ? owner : default;
}
// its followers, as the actor says to Smithereen (wallPosting), unless the persona hides them
public async Task<bool> MayWrite(LocalActor owner, ForeignAvatar author, CancellationToken token) =>
await _dbEntities.Followers.Match(f => f.LocalActorId == owner.Id && f.LocalActorKind == owner.Kind && f.ActorURI == author.ActorURI && f.IsAccepted)
.ExecuteAnyAsync(token)
&& !(await Domain.Relationships.Hidden.RecipientsHiding(new[] { owner.Id }, author.ActorURI, token)).Contains(owner.Id);
public async Task Hosted(PostEntity post, LocalActor owner, CancellationToken token)
{
await Notifications.Add(owner.Id, NotificationType.Mention, post.AuthorAccountId, post.ActorURI, post.ID, token);
var add = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = owner.ActivityUri($"wall-{post.ID}"),
["type"] = "Add",
["actor"] = owner.Uri,
["object"] = post.ObjectURI,
["target"] = new JsonObject { ["type"] = "OrderedCollection", ["id"] = owner.Wall, ["attributedTo"] = owner.Uri },
["to"] = new JsonArray(ActivityPubRenderer.Public, owner.Followers, post.ActorURI)
};
await _delivery.EnqueueToFollowers(owner, add, token, await AuthorInbox(post, token));
}
public async Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token)
{
var objectUri = Id(activity["object"]);
if (objectUri == default)
{
Arrival.Drop("unparseable");
return;
}
if (!add)
{
var walled = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.WallOwnerURI == owner.ActorURI).ExecuteFirstAsync(token);
if (walled == default)
{
Arrival.Drop("unknown-object");
return;
}
await RemoteDeletes.Remove(walled, objectUri, token);
Arrival.Accept("unwalled");
return;
}
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token)
?? await _remotePosts.StoreContext(objectUri, 0, token);
if (post is not { IsFederatedCopy: true, ReblogOfPostId: null, Visibility: PostVisibility.Public or PostVisibility.Unlisted } || !string.IsNullOrEmpty(post.InReplyToURI))
{
Arrival.Drop("unknown-object");
return;
}
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
if (post.WallOwnerURI == owner.ActorURI)
{
Arrival.Drop("duplicate");
return;
}
post.WallURI = owner.WallURL;
post.WallOwnerURI = owner.ActorURI;
post.WallOwnerAccountId = owner.ID;
await DB.Default.Update<PostEntity>().MatchID(post.ID)
.Modify(p => p.WallURI, post.WallURI)
.Modify(p => p.WallOwnerURI, post.WallOwnerURI)
.Modify(p => p.WallOwnerAccountId, post.WallOwnerAccountId)
.ExecuteAsync(token);
await _fanout.Distribute(post, token);
Arrival.Accept("walled");
}
public async Task<bool> Remove(LocalActor owner, PostEntity post, CancellationToken token)
{
if (post == default || !owner.HasWall || post.WallURI != owner.Wall)
return false;
var remove = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = owner.ActivityUri($"unwall-{post.ID}"),
["type"] = "Remove",
["actor"] = owner.Uri,
["object"] = post.ObjectURI,
// (a plain id: Smithereen refuses a Remove whose target is an object)
["target"] = owner.Wall,
["to"] = new JsonArray(ActivityPubRenderer.Public, owner.Followers, post.ActorURI)
};
await _delivery.EnqueueToFollowers(owner, remove, token, await AuthorInbox(post, token));
await RemoteDeletes.Remove(post, post.ObjectURI, token);
return true;
}
async Task<IEnumerable<string>> AuthorInbox(PostEntity post, CancellationToken token)
{
var author = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == post.ActorURI).ExecuteFirstAsync(token);
var inbox = string.IsNullOrEmpty(author?.SharedInboxURL) ? author?.InboxURL : author.SharedInboxURL;
return inbox == default ? Array.Empty<string>() : new[] { inbox };
}
}
}
@@ -143,6 +143,30 @@ namespace PrivaPub.Federation.Controllers
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
}
// a persona's wall (FEP-400e): its own public posts that start a thread, and what its followers wrote on it, newest
// first, as links; Smithereen reads it when one of its accounts starts following the persona
[HttpGet, Route("{actor}/" + Walls.Path)]
public async Task<IActionResult> Wall(string actor, [FromQuery] bool page, [FromQuery(Name = "max_id")] string maxId, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { HasWall: true })
return NotFound();
var wall = local.Wall;
System.Linq.Expressions.Expression<Func<PostEntity, bool>> onWall = p => !p.DeletedAt.HasValue && !p.AuthorGone && !p.IsLocalOnly
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted)
&& (p.WallURI == wall
|| p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null && p.AnsweringToPostId == null && p.InReplyToURI == null && p.GroupId == null);
if (!page)
return Activity(ActivityPubRenderer.OrderedCollection(wall, (int)await DB.Default.CountAsync(onWall, token), default, $"{wall}?page=true"));
var query = _dbEntities.Posts.Match(onWall);
if (!string.IsNullOrEmpty(maxId))
query.Match(f => f.Lt(p => p.ID, maxId));
var latest = await query.Sort(p => p.ID, Order.Descending).Limit(OutboxSize).ExecuteAsync(token);
var pageId = string.IsNullOrEmpty(maxId) ? $"{wall}?page=true" : $"{wall}?page=true&max_id={maxId}";
var next = latest.Count == OutboxSize ? $"{wall}?page=true&max_id={latest[^1].ID}" : default;
return Activity(ActivityPubRenderer.OrderedCollectionPage(pageId, wall, latest.Select(p => (JsonNode)(p.ObjectURI ?? local.PostUri(p.ID))), next, default));
}
// FEP-8fcf: the persona's followers on the server that signs the request, and nobody else's (FollowersSynchronization)
[HttpGet, Route("{actor}/groupies/roll-call")]
public async Task<IActionResult> RollCall(string actor, CancellationToken token)
@@ -42,11 +42,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IQuoteService _quotes;
readonly IInteractionApprovals _approvals;
readonly Relays.IRelays _relays;
readonly IWalls _walls;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews, IQuoteService quotes,
IInteractionApprovals approvals = default, Relays.IRelays relays = default)
IInteractionApprovals approvals = default, Relays.IRelays relays = default, IWalls walls = default)
{
_walls = walls;
_relays = relays;
_approvals = approvals;
_quotes = quotes;
@@ -189,8 +191,17 @@ namespace PrivaPub.Federation.Inbox.Handlers
// forwarded post)
var relayed = !followed && visibility == PostVisibility.Public && _relays != default
&& await _relays.Passes(Arrival.Current?.ForwardedBy, token);
// written on a persona's wall (FEP-400e): public, not a reply, by an account following the persona, as its actor
// tells Smithereen
var wall = _walls == default || note.Wall == default ? default : await _walls.OwnerOf(note.Wall, token);
if (wall != default && (visibility is not (PostVisibility.Public or PostVisibility.Unlisted) || !string.IsNullOrEmpty(note.InReplyTo)
|| note.WallOwner != default && note.WallOwner != wall.Uri || !await _walls.MayWrite(wall, author, token)))
{
Arrival.Drop("wall-refused");
return;
}
if (visibility == PostVisibility.Direct ? persons.Count == 0
: group == default && persons.Count == 0 && !repliesToLocal && !followed && !repliesToFollowed && !inFollowedGroup && !relayed)
: group == default && wall == default && persons.Count == 0 && !repliesToLocal && !followed && !repliesToFollowed && !inFollowedGroup && !relayed)
{
Arrival.Drop("not-addressed");
return;
@@ -212,6 +223,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.ActivityURI = Id(activity);
post.GroupId = group?.Id;
post.ConversationId = conversation?.ID;
if (wall != default)
{
post.WallURI = wall.Wall;
post.WallOwnerURI = wall.Uri;
post.WallOwnerAccountId = wall.Id;
}
try
{
await DB.Default.SaveAsync(post, token);
@@ -235,6 +252,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
await Domain.Statuses.ConversationStates.Posted(conversation.ID, post.ID, default, token);
if (group is { IsCircle: false })
await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: true, token);
if (wall != default)
await _walls.Hosted(post, wall, token);
}
async Task<bool> IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) =>
@@ -6,21 +6,38 @@ using System.Text.Json.Nodes;
namespace PrivaPub.Federation.Inbox.Handlers
{
// An account pins one of its posts (Add to its `featured` collection) or unpins it (Remove), as Mastodon and Akkoma
// send it; inside a community's announce, the community features a post made in it. Any other target (Smithereen's
// wall, a community's moderators) is not ours to keep.
// send it; inside a community's announce, the community features a post made in it. On its wall (Smithereen's, FEP-400e)
// it tells of a post someone wrote there, or takes it off. Any other target (a community's moderators) is not ours to
// keep.
public class FeaturedHandler : IActivityHandler
{
readonly IFeaturedPosts _featured;
readonly IWalls _walls;
readonly IRemoteActorService _remoteActors;
readonly bool _add;
public FeaturedHandler(IFeaturedPosts featured, bool add)
public FeaturedHandler(IFeaturedPosts featured, bool add, IWalls walls = default, IRemoteActorService remoteActors = default)
{
_featured = featured;
_walls = walls;
_remoteActors = remoteActors;
_add = add;
}
public string Type => _add ? "Add" : "Remove";
public Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) => _featured.Receive(activity, actor, _add, token);
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var target = Objects.ActivityJson.Id(activity["target"]);
// a collection of its own we do not know of: its document read again, at most hourly, in case it was kept before
// we read that collection (a wall)
if (_remoteActors != default && target != default && target != actor.FeaturedURL && target != actor.WallURL
&& Objects.Origin.Same(target, actor.ActorURI) && actor.UpdatedAt < DateTime.UtcNow.AddHours(-1))
actor = await _remoteActors.GetActor(actor.ActorURI, refresh: true, token) ?? actor;
if (_walls != default && !string.IsNullOrEmpty(actor.WallURL) && target == actor.WallURL)
await _walls.Receive(activity, actor, _add, token);
else
await _featured.Receive(activity, actor, _add, token);
}
}
}
@@ -24,6 +24,8 @@ namespace PrivaPub.Federation.Objects
public string InReplyTo { get; init; }
public string Context { get; init; }
public string Audience { get; init; }
public string Wall { get; init; }//FEP-400e: the collection it says it was written in (`target`), a wall
public string WallOwner { get; init; }//that collection's attributedTo
public string QuoteUri { get; init; }
public string ReplyAuthorization { get; init; }//the parent author's ReplyAuthorization (approvedBy before GoToSocial 0.21)
public string QuoteAuthorization { get; init; }
@@ -89,6 +91,8 @@ namespace PrivaPub.Federation.Objects
InReplyTo = Id(note["inReplyTo"]),
Context = Id(note["context"]) ?? Value(note, "conversation"),
Audience = Id(note["audience"]),
Wall = note["target"] is JsonObject target && Value(target, "type") is "Collection" or "OrderedCollection" ? Id(target) : default,
WallOwner = note["target"] is JsonObject wallTarget ? Id(wallTarget["attributedTo"]) : default,
QuoteUri = Id(note["quote"]) ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote") ?? QuoteLink(note),
ReplyAuthorization = Id(note["replyAuthorization"]) ?? Id(note["approvedBy"]),
QuoteAuthorization = Id(note["quoteAuthorization"]),
@@ -72,7 +72,14 @@ namespace PrivaPub.Federation.Rendering
["value"] = "schema:value",
["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger",
["lemmy"] = "https://join-lemmy.org/ns#",
["postingRestrictedToMods"] = "lemmy:postingRestrictedToMods"
["postingRestrictedToMods"] = "lemmy:postingRestrictedToMods",
// Smithereen's, which it compacts what it reads by: an allowedTo stays a plain string, as it reads them
["sm"] = "http://smithereen.software/ns#",
["wall"] = new JsonObject { ["@id"] = "sm:wall", ["@type"] = "@id" },
["privacySettings"] = "sm:privacySettings",
["wallPosting"] = "sm:wallPosting",
["wallPostVisibility"] = "sm:wallPostVisibility",
["allowedTo"] = "sm:allowedTo"
});
public static string Html(string markdown) =>
@@ -128,6 +135,16 @@ namespace PrivaPub.Federation.Rendering
["value"] = FieldValue(field.Value)
}).ToArray())
};
// a persona's wall (FEP-400e, owner decision 2026-10-06): its followers write on it, everyone reads it
if (actor.HasWall)
{
document["wall"] = actor.Wall;
document["privacySettings"] = new JsonObject
{
["wallPosting"] = new JsonObject { ["allowedTo"] = new JsonArray(actor.Followers) },
["wallPostVisibility"] = new JsonObject { ["allowedTo"] = new JsonArray(Public) }
};
}
if (actor is { Kind: LocalActorKind.Group, IsCircle: false })
{
document["attributedTo"] = actor.Wardens;
+1
View File
@@ -22,6 +22,7 @@ namespace PrivaPub.Infrastructure.Data
await Plain<Post>(token, p => p.GroupUserId, p => p.ID);
await Plain<Post>(token, p => p.GroupId, p => p.ID);
await Plain<Post>(token, p => p.ActorURI);
await Plain<Post>(token, p => p.WallURI, p => p.ID);//a persona's wall (FEP-400e)
await Plain<Post>(token, p => p.ConversationId, p => p.ID);
await Plain<Post>(token, p => p.InReplyToURI);
// who boosted what: a persona's boosts of the posts a page shows were a scan of every post under load
@@ -95,8 +95,11 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, BlockHandler>()
.AddSingleton<IActivityHandler, LockHandler>()
.AddSingleton<Federation.Actors.IFeaturedPosts, Federation.Actors.FeaturedPosts>()
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: true))
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: false))
.AddSingleton<Federation.Actors.IWalls, Federation.Actors.Walls>()
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: true,
services.GetRequiredService<Federation.Actors.IWalls>(), services.GetRequiredService<Federation.Actors.IRemoteActorService>()))
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: false,
services.GetRequiredService<Federation.Actors.IWalls>(), services.GetRequiredService<Federation.Actors.IRemoteActorService>()))
.AddSingleton<IActivityHandler, MoveHandler>()
.AddSingleton<IActivityHandler, CreateHandler>()
.AddSingleton<IActivityHandler, DeleteHandler>()
+3
View File
@@ -14,6 +14,9 @@ namespace PrivaPub.Models.Post
public string GroupId { get; set; }
public string ConversationId { get; set; }//DmGroup.ID of a direct post
public string AudienceURI { get; set; }//a remote community the post was made in
public string WallURI { get; set; }//the wall it was written on (FEP-400e): a persona's here, or another account's elsewhere
public string WallOwnerURI { get; set; }//that wall's owner, whose followers it reaches
public string WallOwnerAccountId { get; set; }//the owner's Avatar.ID or ForeignAvatar.ID
public string ReblogOfPostId { get; set; }
public PostVisibility Visibility { get; set; }
public string Title { get; set; }
+1
View File
@@ -76,6 +76,7 @@ namespace PrivaPub.Models.User
public string InboxURL { get; set; }
public string OutboxURL { get; set; }
public string FeaturedURL { get; set; }//featured: the posts it pins
public string WallURL { get; set; }//sm:wall (FEP-400e): where others write to it, Smithereen's walls
public string MovedToURL { get; set; }
public List<string> AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is
public string PictureURL { get; set; }//icon
+13 -8
View File
@@ -850,10 +850,13 @@ without a port, before it gives out its OAuth client.
### Smithereen 1.0.3
- **Walls:** a post is a `Note`; one written on someone else's wall carries the wall (`sm:wall`, FEP-400e) as its
`target`, and the wall's owner tells its followers with `Add{Note}`. Smithereen sends both only to servers whose
actors have a wall (`Server.Feature.WALL_POSTS`, learnt from an actor's `sm:wall`), so PrivaPub never sees a post on
someone else's wall (G-0009: a persona's wall waits for the owner). Comments are replies addressed to the post's
author, never to followers.
`target` (an object with the owner as `attributedTo`), and the wall's owner tells its followers with `Add{Note}`.
Smithereen sends both only to servers whose actors have a wall (`Server.Feature.WALL_POSTS`, set for a whole domain
the first time it reads an actor there with `sm:wall`, never unset). It reads properties through JSON-LD compaction, so
`wall`, `privacySettings` and its keys need their `sm:` terms in the context, and an `allowedTo` must stay a plain
string. Its posting rule takes one base rule (followers, or following, or friends), so a persona says followers. An
owner deleting someone else's post on its wall sends nothing; a `Remove` from the owner (its target a plain id)
deletes the post there. Comments are replies addressed to the post's author, never to followers.
- **Friends are mutual follows.** A request is an `Offer{Follow}` sent only to actors with
`sm:supportsFriendRequests`; anyone else gets a plain `Follow`, so a persona is followed, and following back makes
them friends there.
@@ -865,12 +868,14 @@ without a port, before it gives out its OAuth client.
`requireNonNullElse` was meant); its HTTP client refuses private addresses; its schema updater makes stored
functions, which MySQL takes only with `log_bin_trust_function_creators`. A database whose first update failed is
left without its triggers, and the server then answers 404 to activities about anything it knows.
- **Pasture evidence (2026-10-05, Smithereen 1.0.3, `tools/pasture/scenarios/smithereen.sh`):** 30 checks pass and one
gap is expected (G-0009): smuser follows alice and she follows back, which makes them friends there; wall posts both
ways; comments both ways; likes both ways; smuser's repost is a quote alice's policy lets in, and alice's boost is a
- **Pasture evidence (2026-10-06, Smithereen 1.0.3, `tools/pasture/scenarios/smithereen.sh`):** 40 checks pass:
smuser follows alice and she follows back, which makes them friends there; Smithereen learns that PrivaPub's actors
have walls; wall posts both ways; smfriend's post on smuser's wall reaches alice as on that wall (G-0009 closed);
smuser writes on alice's wall, which she sees and her wall lists, her followers' servers are told, and taking it off
deletes it on Smithereen; comments both ways; likes both ways; smuser's repost is a quote alice's policy lets in, and alice's boost is a
repost there; smuser's poll and alice's vote; edits and deletions both ways; private messages both ways; the
unfollow; statistics. PrivaPub changed for it: a server description that failed is asked again on the server's next
arrival instead of a week later.
arrival instead of a week later; walls (owner decision 2026-10-06).
### Loops (1.0.0-beta.14) and Pixelfed (0.14.4)
+1 -1
View File
@@ -282,7 +282,7 @@ and circles (see Owner decisions).
| Question | Decision |
|---|---|
| A persona's wall (G-0009) | **Yes, publish walls.** A persona's actor names its wall (`sm:wall`, FEP-400e); Smithereen's users may write on it, PrivaPub hosts their posts there and tells the persona's followers with `Add{Note}`. |
| A persona's wall (G-0009) | **Yes, publish walls.** A persona's actor names its wall (`sm:wall`, FEP-400e); Smithereen's users may write on it, PrivaPub hosts their posts there and tells the persona's followers with `Add{Note}`. **Done 2026-10-06:** its followers write on it, the persona takes posts off; posts on walls elsewhere reach their owners' followers here; checked live against Smithereen. |
| Personas' public posts and relays | **Send public posts too.** A persona's public post (not unlisted, not followers-only, not local-only) also goes to the relays PrivaPub subscribes to, as Mastodon sends them. |
| A post's `replies` and `context` | **Publish public threads.** Public and unlisted posts name their `replies` and `context` collections, which list only the public and unlisted replies PrivaPub holds; followers-only, circle, direct and local-only posts never do. |
| FEP-8fcf followers synchronisation | **Send digests.** A delivery to a server that the persona's followers there would get carries a `Collection-Synchronization` header: a digest of the persona's followers on that server only, and where that server reads that partial list. |
+31 -3
View File
@@ -40,6 +40,10 @@ until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$sm_
&& ok "alice follows smuser (Accept arrived)" || ko "smuser's Accept never arrived"
until_true 30 '[ "$(sm friends.areFriends "user_ids=$alice_on_sm" | j "print(d[\"response\"][0][\"friend_status\"])")" = "friends" ]' \
&& ok "the two follows make them friends on Smithereen" || ko "Smithereen does not count them as friends ($(sm friends.areFriends "user_ids=$alice_on_sm"))"
# (alice's profile, told again: Smithereen learns of her wall, and that PrivaPub's actors have walls, when it reads her)
curl -s -o /dev/null -X PATCH -H "$PH" "$P/api/v1/accounts/update_credentials" -d "note=alice of the pasture"
until_true 30 '[ -n "$(sm_sql "select 1 from servers where host='"'privapub.test'"' and features & 1")" ]' \
&& ok "Smithereen learns that PrivaPub's actors have walls" || ko "Smithereen never took PrivaPub for a server with walls"
echo " walls"
sm_post=$(sm wall.post "message=a Smithereen wall post $run" | j "print(d['response'])")
@@ -104,13 +108,37 @@ echo " someone else's wall"
# smfriend writes on smuser's wall: the post carries the wall as its target, and smuser tells its followers' servers
# with Add{Note}, which is no pin
read -r _ sm_on_friend <<< "$(SMTOKEN=$SFT sm_load "https://smithereen.test/users/$sm_self")"
smf wall.post "owner_id=$sm_self" "message=a word on smuser's wall $run" >/dev/null
smf wall.post "owner_id=$sm_self" "message=a word on the wall of smuser $run" >/dev/null
# (Smithereen sends a post on someone else's wall, and its owner's Add, only to servers whose actors have a wall: G-0009)
until_true 20 '[ -n "$(p_home_has "a word on smuser'"'"'s wall $run")" ]' && ok "a post on smuser's wall reaches alice" \
|| xf "a post on smuser's wall never reaches alice: Smithereen sends it only to servers with walls (G-0009)"
until_true 45 '[ -n "$(p_home_has "a word on the wall of smuser $run")" ]' && ok "a post on smuser's wall reaches alice" \
|| ko "a post on smuser's wall never reaches alice"
on_wall=$(p_home_has "a word on the wall of smuser $run")
[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$on_wall" | j "print((d['privapub'].get('wall') or {}).get('acct'))")" = "smuser@smithereen.test" ] \
&& ok "as written on smuser's wall" || ko "PrivaPub does not say it is on smuser's wall"
[ "$(curl -s -H "$PH" "$P/api/v1/accounts/$sm_on_p/statuses?pinned=true" | j "print(len(d))")" = "0" ] \
&& ok "and is not taken for a pin" || ko "PrivaPub took the wall's Add for a pin"
echo " alice's wall"
# smuser follows alice, so it may write on her wall (her actor says her followers may): PrivaPub hosts the post, tells
# alice, lists it on her wall and tells her followers' servers with Add; alice takes it off, which deletes it on Smithereen
sm_wall_post=$(sm wall.post "owner_id=$alice_on_sm" "message=a word on alice's wall $run" | j "print(d.get('response', ''))")
[ -n "$sm_wall_post" ] && ok "Smithereen lets smuser write on alice's wall" || ko "Smithereen refused smuser's post on alice's wall ($(sm wall.post "owner_id=$alice_on_sm" "message=x" | head -c 200))"
p_mention() { curl -s -H "$PH" "$P/api/v1/notifications?types[]=mention&limit=40" | j "print(next((n['status']['id'] for n in d if 'a word on alice' in n['status']['content'] and '$run' in n['status']['content']), ''))"; }
until_true 45 '[ -n "$(p_mention)" ]' && ok "the post on alice's wall reaches her" || ko "the post on alice's wall never reached her"
walled=$(p_mention)
[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$walled" | j "print((d['privapub'].get('wall') or {}).get('username'))")" = "alice_smithereen" ] \
&& ok "as written on her wall" || ko "PrivaPub does not say it is on alice's wall"
walled_uri=$(curl -s -H "$PH" "$P/api/v1/statuses/$walled" | j "print(d['uri'])")
pfetch -s -H 'Accept: application/activity+json' "$alice_uri/graffiti?page=true" | grep -q "$walled_uri" \
&& ok "her wall lists it" || ko "her wall does not list it"
# (the Add delivered to Smithereen, done: Smithereen holds the post already, its author's; the body's quotes are \u0022)
p_adds_done() { podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Job.countDocuments({Host:"smithereen.test", State:2, Payload:/type\\u0022:\\u0022Add\\u0022.*graffiti/}))'; }
until_true 30 '[ "$(p_adds_done)" -ge 1 ]' && ok "alice tells her followers' servers with Add" || ko "no Add reached Smithereen"
curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$walled"
until_true 45 '[ "$(sm_sql "select count(*) from wall_posts where id=$sm_wall_post")" = "0" ]' \
&& ok "alice takes it off her wall, and Smithereen deletes it" || ko "the post is still on Smithereen after alice took it off"
[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$walled")" = "404" ] && ok "and it is gone here" || ko "it is still here"
echo " private messages"
sm messages.send "to=$alice_on_sm" "body=a Smithereen message $run" >/dev/null
until_true 45 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a Smithereen message $run"' && ok "smuser's message arrives as a DM" || ko "smuser's message never reached alice"
+3 -2
View File
@@ -134,7 +134,8 @@
"phase": "P3",
"code": "smithereen 1.0.3 ActivityPubWorker.sendAddPostToWallActivity and sendActivityForPost: requireFeature(Server.Feature.WALL_POSTS), which ObjectLinkResolver.maybeUpdateServerFeaturesFromActor sets only for a server whose actors have sm:wall",
"opened": "2026-10-05",
"status": "open",
"note": "Waits for the owner: a persona would publish a wall (sm:wall, FEP-400e), which also offers Smithereen's users to write on it, and PrivaPub would host their posts and announce them with Add{Note}."
"status": "closed",
"note": "Closed by the owner's decision of 2026-10-06: a persona's actor names its wall (sm:wall, FEP-400e), so Smithereen sends PrivaPub what is written on its users' walls (Add{Note}, shown to their followers here as on that wall), and its users may write on a persona's wall when they follow it. Checked live: Smithereen 1.0.3 scenario, 40 checks.",
"closed": "2026-10-06"
}
]