15 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 e6729c77e7 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
2026-10-01 19:53:13 +02:00
thepraandClaude Opus 5.5 6f1ab0073c P6: quote posts, received and sent (FEP-044f and the older keys)
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 54s
- Received quotes: read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 Link tag; the quoted post
  is fetched once; a quoteAuthorization stamp is verified field by field on the quoted author's origin; a consent
  quote without a stamp is pending; an older-key quote of a public post is shown; Delete of a stamp revokes. Counts
  and a `quote` notification follow the accepted state. The `quote-inline` fallback survives sanitising and is removed
  from content when the real quote is shown.
- Personas quote through `quoted_status_id`: posts that state a quote policy get a QuoteRequest and stay pending until
  an Accept brings a stamp we can verify, then an Update adds quoteAuthorization; posts that state none are quoted
  the older way, without `quote`; another persona's posts cannot be quoted yet (we issue no stamps). Quoting posts
  are delivered to the quoted author too.
- Mastodon API: Status.quote (with the quoted status one level deep), quotes_count, quote_approval from the remote
  policy, GET /api/v1/statuses/:id/quotes, `quote` notifications, and api_versions.mastodon = 7.

Checked live: GoToSocial's author-only quote policy is respected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:53:28 +02:00
thepraandClaude Opus 5.5 fdcf5176bc P6: emoji reactions in and out
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 57s
- Incoming reactions in all three shapes: Misskey and Sharkey Likes carrying an emoji (`content` or
  `_misskey_reaction`), Pleroma, Akkoma and Iceshrimp.NET EmojiReacts, and their Undo. Unicode reactions are one
  grapheme; custom ones need their Emoji tag and keep its image; `:name@host:` is read as `name`. A Like whose
  content is a heart stays a favourite.
- Reactions are kept per account and emoji on our posts and on remote ones we hold, and the author of a local post
  gets a `pleroma:emoji_reaction` notification with the emoji.
- Personas react through Pleroma's API (PUT/DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji, GET for the list),
  which sends an EmojiReact (or its Undo) to the post's author.
- Statuses carry `emoji_reactions` (read by Phanpy) and `pleroma.emoji_reactions`, with counts and whether the viewer
  reacted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:39:18 +02:00
thepraandClaude Opus 5.5 7be6749a23 P6: custom emoji, fuller remote profiles, and polls both ways
Build / Build (push) Successful in 39s
Deploy / privapub.thepra.dev (push) Successful in 57s
- Custom emoji (Emoji tags) on posts, display names, bios and profile fields, at most 64 per object, proxied, in
  Status.emojis and Account.emojis.
- Remote profiles keep their header, profile fields, locked flag, published date, movedTo, indexable, memorial and
  image descriptions (a locked GoToSocial account no longer shows as open).
- Polls: incoming Questions (Mastodon, Misskey, Pleroma, GoToSocial shapes) with counts, voters, end and closed;
  our own polls from the Mastodon API go out as Questions; votes in are counted once per voter and never become
  replies; personas vote on other servers' polls with one Note per choice; counts refresh with an Update at most every
  three minutes; a poll closes on time and tells its voters and its author. GET /api/v1/polls/:id and POST
  /api/v1/polls/:id/votes.
- An Update without a newer `updated` only refreshes poll, video, audio and event details and leaves no revision.

Checked live against GoToSocial: each side's poll reaches the other as a poll and each side's vote is counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:35:20 +02:00
thepraandClaude Opus 5.5 bb9bd71391 P5, first batch: summaries stop hiding articles, personas get reachable names, blocks federate
Build / Build (push) Successful in 50s
Deploy / privapub.thepra.dev (push) Successful in 1m4s
- A remote `summary` is a content warning only on a Note or Question, or when `sensitive` is set. On Articles,
  Events, Videos, Pages and Audio it is an excerpt (WordPress teasers, Mobilizon dates and places, Mbin titles) and
  is now kept as `Post.Excerpt` instead of hiding the post. `Post.ObjectType` records the remote type, and the
  Mastodon API shows a remote non-Note object's title above its body again.
- Persona usernames must match `^[a-z0-9_]+$`, as groups already did; a name outside it was unreachable from
  Mastodon and Misskey.
- `postingRestrictedToMods` is defined in our JSON-LD context (Iceshrimp.NET drops undefined terms).
- `Vary: Accept` on actor and object URLs.
- Owner decision: blocks federate. A block sends `Block`, an unblock `Undo{Block}`; checked live against GoToSocial.
- Owner decision: a persona's and a group's `published`, and the day in new ids, is a random day up to two weeks
  before creation, so personas made the same day no longer share a date. Migration _007 gives existing ones theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:39:58 +02:00
thepraandClaude Opus 5.5 31542a181e Remote posts take their timeline place on arrival, and an unedited one has no edit date
Found by the first live run against GoToSocial:

- A remote post's id came from its published second plus random bytes, so a reply arriving in the same second as
  the post it answers could sort under it, and a late arrival landed behind a client's since_id. A post published
  within the last hour now gets an id for its arrival; backfill keeps its published time.
- NoteParser.Time returned default(DateTime) for a missing "updated", so every remote post was stored as edited in
  year one. Migration _006 clears the stored ones.
- published now carries milliseconds, so peers that derive ids from it (GoToSocial, Mastodon) keep our posts in
  order within a second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 13:19:16 +02:00
thepraandClaude Opus 5.5 51f2c10c6f Community actors name their moderators the way Lemmy reads them
Build / Build (push) Successful in 41s
Deploy / privapub.thepra.dev (push) Successful in 1m1s
attributedTo points at /wardens and postingRestrictedToMods reflects the
posting policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:31:13 +02:00
thepraandClaude Opus 5.5 a5d9a89445 Communities follow FEP-1b12, circles federate to their members only
Communities:
- a post addressed to a community (to, cc or audience) is accepted
  according to its posting policy - followers, anyone, or moderators -
  and GroupDistributor announces the whole activity with `audience` to
  the community's followers, plus the object for new posts so Mastodon
  shows them; updates and deletes of community content are announced too;
- top-level posts are Pages with a name (the title, or a headline from
  the text); /flock counts members, /wardens lists moderators;
- a Mastodon client posts into a community by mentioning it, or into a
  remote group, which sets `audience`;
- an Announce of an activity from a remote group a persona follows (Lemmy)
  is followed through: the object is fetched from its own origin, kept with
  its AudienceURI, and fanned out to the group's local followers; updates
  are applied in place and deletes checked against the origin.

Circles stop being local-only: an undiscoverable Group actor whose follows
are all requests the owner approves; posts addressed to the circle and its
/flock and delivered to members' own inboxes, never announced, never
public; a remote member's post into the circle is accepted from members
only. SignedFetchAuthorizer serves circle posts and collections only to a
signed request from a member or a member server's instance actor - 404 for
anyone else. Circles never surface in search, lookups, mentions, account
ids or profile pages.

Federation:SecureMode requires a valid signature on every GET under
/peasants except the instance actor. Group forms take a posting policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:30:57 +02:00
thepraandClaude Opus 5.5 4a713f3fb6 Media: uploads stripped of metadata, attachments both ways, a remote media proxy
- /api/v1/media and /api/v2/media (and GET/PUT /api/v1/media/:id):
  images go through libvips (NetVips, its native build bundled):
  autorotated, every kind of metadata dropped (EXIF, GPS, XMP, IPTC,
  comments), capped at 4096 px, with a 640 px preview and a blurhash
  (own encoder, the reference algorithm); animated GIFs are re-encoded;
  video and audio are remuxed by ffmpeg with -map_metadata -1, never
  re-encoded, and a video gets a still preview. Files get random names
  under /var/lib/privapub/media, outside the web root deploys replace, and
  are served at /media/files with nosniff and a sandbox CSP.
- media_ids on create and edit (four at most, the persona's own, each used
  once); notes carry them as Document attachments with alt text, blurhash,
  focalPoint and size; inbound attachments were already kept.
- avatar and header uploads in update_credentials, cropped to 400x400 and
  1500x500, federated with Update{Person}.
- Remote media reaches clients only through /media/proxy/{hmac}/{url},
  fetched by the guarded client (no SVG, 40 MB cap) and cached outside the
  served root, trimmed to 5 GB; foreign avatars and headers use it too, so
  a client never contacts another server.
- MediaJanitor deletes uploads left unattached for a day.
- nginx accepts 100 MB bodies on the upload endpoints only (applied on Max).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:22:08 +02:00
thepraandClaude Opus 5.5 8f75317050 Blocks, mutes, bookmarks, pins and reports
- Blocks are per persona and never federate (a Block activity would tell
  the other server who blocked whom): the blocked account is removed as a
  follower, with a Reject{Follow} if it is remote, unfollowed, cleared from
  home and notifications, and refused with Reject if it follows again.
- Mutes (optionally timed, optionally sparing notifications) and per-
  persona domain blocks keep authors out of home timelines, notifications
  and every status list the API returns; the boosts of a hidden author's
  posts are hidden too.
- Bookmarks and pins (at most five, public or unlisted, own posts) with
  their Mastodon endpoints and flags; pinned posts are the actor's
  `featured` collection at /trophies, and `featuredTags` points at
  /tattoos.
- Reports: /api/v1/reports stores the report and, when forwarding to a
  remote account, sends Flag from the instance actor, so the reporting
  persona is never named to the other server. An inbound Flag about a local
  persona or its posts becomes a report; moderators list and resolve them
  under /clientapi/moderator/reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:13:57 +02:00
thepraandClaude Opus 5.5 0028e96e76 The Mastodon client API: accounts, statuses, timelines, notifications, search
With a token for a persona, a Mastodon client can now:
- accounts: verify/update credentials (display name, note, fields, locked,
  bot, discoverable, indexable, hide collections, posting defaults; the
  change federates as Update{Person}), get, lookup, statuses (paged, by
  visibility to the viewer), relationships, search, follow and unfollow,
  follow requests (authorize and reject answer remote followers with
  Accept or Reject), remove from followers. Followers and following lists
  are shown to their owner only.
- statuses: post (plain text, mentions, hashtags, replies, content
  warnings, the four visibilities, Idempotency-Key), get, edit (PUT),
  delete returning the source for redrafting, context, history, source,
  favourite, reblog and their undos, favourited_by and reblogged_by.
- timelines: home, public (local or remote), tag; favourites;
  conversations; markers.
- notifications: list with types and exclude_types, get, dismiss, clear,
  unread count.
- /api/v2/search, resolving a handle or a URL to an account or a post.

Media, polls, pins, bookmarks, mutes, blocks, lists, filters, trends and
push answer empty lists or a 422 saying they are not supported yet, so
clients degrade instead of failing. Public reads work without a token.
Persona settings (locked, bot, indexable, discoverable) now also shape
the ActivityPub actor.

Fixed on the way: three conditional expressions whose `default` was the
value type's, so a missing limit became 1, a missing flag became false and
an attachment without dimensions became 0x0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:05:58 +02:00
thepraandClaude Opus 5.5 4d9be37c1c Mastodon clients can sign in: OAuth with a persona per token
OpenIddict 7.7 (MongoDB stores, keys kept in Mongo so tokens survive
restarts) serves /oauth/authorize, /oauth/token, /oauth/revoke and the
discovery documents, including /.well-known/oauth-authorization-server:
- authorization code (PKCE optional) and client credentials, Mastodon's
  scopes including the granular ones, non-expiring reference tokens,
  `created_at` in the token response, and the urn:ietf:wg:oauth:2.0:oob
  page that shows the code;
- /oauth/login signs the private login into a fifteen-minute cookie that
  only /oauth sees (rate limited, antiforgery-protected); /oauth/authorize
  then asks which persona the application acts as. The token's subject
  is that persona's id and nothing else; no root id reaches a token, an
  authorization or a log line;
- the token exchange refuses a persona whose login is banned or deleted,
  and every API request checks the same.

/api/v1/apps registers applications dynamically, /api/v1/apps/
verify_credentials, /api/v1/instance (v1 and v2, "4.2.0 (compatible;
PrivaPub)") and verify_credentials answer in Mastodon's shapes: snake_case
with explicit nulls, Rails-style parameters from query, form or JSON,
{"error": ...} on failure, Link paging. CORS exposes Link.

/api goes to OpenIddict validation and everything else to the existing
JWT; the JWT failure handler no longer sends the exception and stack trace
to the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:52:31 +02:00
thepraandClaude Opus 5.5 e54e17708f Outbound documents speak Mastodon's dialect under PrivaPub's route names
Local text (Domain/Content/ContentRenderer): Markdig with raw HTML off and
autolinks, or plain text for API clients; then @user and @user@host are
resolved (locally or through WebFinger) into Mastodon's h-card markup and
#tags into hashtag links, skipping code, links and e-mail addresses. A
post stores the result, its mentions and its tags, and is delivered to
the mentioned actors and the parent's author as well as to followers.

Renderer:
- Mastodon's @context (toot, schema PropertyValue, discoverable,
  indexable, blurhash, focalPoint, featured, alsoKnownAs, movedTo) and
  FEP-2c59's webfinger;
- an actor's url is its HTML page, its fields are PropertyValue
  attachments, published is cut to the day and indexable is false;
- a note's content is the stored rendering; a title becomes name and a
  bold first line, a content warning without its own text is summarised
  by the title or "Content warning"; Mention and Hashtag tags, contentMap,
  updated, and to/cc by visibility.

Routes take the agreed names: /groupies, /stalking, /scribbles/{id},
/grunts/{id} (a Create resolves), /whispers/{id} for a DM's context. The
outbox is a collection with a first page, paged by max_id. A browser
asking for an actor or a note is redirected to /@user, and WebFinger's
profile-page points there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:21:31 +02:00
thepraandClaude Opus 5.5 e9529310fe One Post model for every visibility, read from notes the way peers write them
Post gains what federation and the Mastodon API need: Visibility (Public,
Unlisted, FollowersOnly, Direct, Circle, LocalGeo), the author's account
id, to/cc, the Create's id, url, context, quote, InReplyToURI and the
parent's author, a separate SpoilerText next to the title, language,
mentions, hashtags, remote attachments (alt text, blurhash, focus, size),
reply/favourite/reblog counters, revisions, EditedAt and DeletedAt.

Direct messages are Posts with Visibility Direct and a ConversationId;
migration _005 copies DmPost rows across with their ids and fills the new
fields of existing posts. DmPost is left in place so a rollback still sees
the old messages.

Inbound:
- NoteParser reads Note, Article, Page, Question and media types: content,
  then contentMap, then _misskey_content; summary as the spoiler and name
  as the title; Mention and Hashtag tags; attachments; a PeerTube-style
  list attribution prefers the person over the channel; quote URIs.
- Addressing classifies like Mastodon, finding followers-only by the
  author's own followers URL (now stored on ForeignAvatar), not by a
  "/followers" suffix.
- Create keeps a post when a local persona is addressed or mentioned, when
  it replies to a local post (the parent's reply count goes up) or when a
  community it follows is addressed; an unsolicited public post is not
  stored. Update keeps the previous version as a revision.

The outbox and object endpoints serve only Public and Unlisted posts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:17:14 +02:00
thepraandClaude Opus 5.5 034b792801 Move the federation code under Federation/, namespaces only
Build / Build (push) Successful in 16s
Services/Federation and Controllers/ServerToServer become
Federation/{Actors,Signing,Inbox,Outbox,Rendering,Controllers}, the first step
of the roadmap's layout. No type, route or behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:39:41 +02:00