T12, T13: Sharkey and Akkoma in the pasture, and two bugs Akkoma found
Build / Build (push) Successful in 4m51s
Deploy / privapub.thepra.dev (push) Successful in 5m2s

Sharkey 2025.4.7 is the Misskey peer under another name, image, database, Redis db and
home. Its scenario is Misskey's plus edits both ways and the FEP-e232 quote tag: 40 checks
pass.

Akkoma 3.20.1 publishes no image, so tools/pasture/images/akkoma installs its OTP
release, pinned by checksum, and appends Caddy's CA to the CA bundles the release ships.
Its scenario has 44 checks, which pass three runs in a row:
- follows, posts, CW, followers-only posts and the published time;
- replies, likes, boosts and their undos;
- EmojiReact both ways and withdrawn;
- DMs, polls, quotes, media, edits with history and deletes, both ways;
- unfollow, block, unblock and statistics.

The two bugs, both fixed:
- Followers-only posts arrived as DMs on Pleroma and Akkoma. They call a post private
  only if an address in `to` contains "/followers" or its cc is not empty. Ours is
  /groupies, and a post mentioning nobody had an empty cc. The followers collection is now
  named in cc as well, which tells nobody anything new.
- Akkoma's open polls showed as ended and refused votes. Akkoma carries an open poll's
  end in `closed` and sends no `endTime`. A `closed` in the future is now read as the end.

Neither of these is a PrivaPub bug:
- Akkoma's Linkify never takes @user@host.test for a mention, so its DM addresses alice
  with to[].
- Its API never reports a remote blocker as blocked_by, so the block is read from its
  database.

Also in this commit:
- The rate limits are configuration (RateLimits: AccountsPerMinute, InboxBurst,
  InboxPerTenSeconds), with the old values as defaults. The pasture raises the accounts
  limit, which back-to-back runs from one address had hit.
- A new Lemmy never sends what it queued for a server before it started that server's
  send worker, so the scenario waits for the worker before its first follow.

All six peers in one clean pass: GoToSocial 54 (+1 expected), Mastodon 49 (+2), Misskey
35, Sharkey 40, Akkoma 44, and Lemmy 20 (+3) once the worker wait was added. 642 tests
pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-03 15:24:08 +02:00
1 parent aa7e43a61e
commit ab526ed291
22 files changed
+361 -19

No files matched your search

+13 -2
View File
@@ -288,7 +288,8 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
counts with bare Updates.
- **A poll vote is a `Note` with a `name`, an `inReplyTo` that is a poll we hold, and no content.** `CreateHandler`
hands it to `PollService.Receive` before anything else, so it never becomes a reply. Our votes on other servers' polls
go only to the poll's author, without `published` (PieFed counts a vote only then).
go only to the poll's author, without `published` (PieFed counts a vote only then). A `closed` in the future is the
poll's end, not its closing: Akkoma sends an open poll's end only there (`ObjectShapes`).
- **Link previews follow owner decision 1** (`Domain/Content/LinkPreviews.cs`): only public posts, queued on arrival with
0–60 s of jitter, one cached `LinkPreview` per address for the whole server, never fetched when someone reads.
`LinkPreviews.Wanted` is called where posts are saved (`CreateHandler`, `StoreContext`, `StatusService.Publish`).
@@ -414,13 +415,23 @@ tools/pasture/run.sh down # removes e
(`federation: none`) until `admin/update-meta` says `all`, which `misskey_up` does. Its API is `POST /api/<endpoint>`
with the token as `i` (`mk` in the scenario); `users/relation` answers a list, `users/notes` leaves replies out unless
asked, and a user has one reaction per note. Never verify a delivery with `ap/show`: it fetches. 35 checks.
- **Sharkey (2025.4.7):** `peers/sharkey.sh` is the Misskey peer under another name, image, database, Redis db and
home (`/sharkey/.config`); `scenarios/sharkey.sh` runs Misskey's scenario with `MISSKEY_NAME=sharkey`, then follows
again (Misskey's ends unfollowed and blocked) and checks edits both ways and the FEP-e232 quote tag. 40 checks.
- **Akkoma (3.20.1):** no official image, so `images/akkoma` installs the OTP release (pinned by checksum; the "stable"
zip moves, and a moved one fails the build) and `akkoma_up` builds it once. Its HTTP clients read the CA bundles
shipped in the release (CAStore, certifi), so the entrypoint appends Caddy's CA there. `pleroma_ctl` passes its
arguments on unquoted: no value may contain a space. Its Linkify never takes `@user@host.test` for a mention, so the
scenario addresses alice with Pleroma's `to[]`; its API never reports a remote blocker as `blocked_by`, so the block is
read from its `user_relationships`. 44 checks.
- **Lemmy (1.0.0-beta.2):** the backend alone on the shared Postgres, its admin made by `setup` in the generated
`config.hjson`. It trusts Caddy's CA through `SSL_CERT_FILE` and reaches the network through
`DANGER_FEDERATION_ALLOW_LOCAL_IP=1`; 0.19 cannot join (its rustls trusts only its bundled roots). Its API is
`/api/v4/<path>` with a bearer token (`lm` in the scenario), and `sort` values are lowercase. It logs no refused
activity at `warn` (`LEMMY_LOG` sets `RUST_LOG`); the reason is in the 400's body. It answers our community's echo of
its own activity and every bare `Announce{object}` 400 by design, and the echo is still needed (see
`docs/INTEROP.md`, Lemmy). 20 checks; relayed votes and a moderator's removal are expected failures (P7).
`docs/INTEROP.md`, Lemmy). A new Lemmy never sends what it queued for a server before its send worker for that
server started, so the scenario waits for that worker (`lm_worker`) before its first follow. 20 checks; relayed votes and a moderator's removal are expected failures (P7).
- **Crawler:** `PRIVAPUB_ENV="Statistics__Crawler__Enabled=true Statistics__Crawler__Seeds__0=mastodon.test"
run.sh up mastodon`, then `interop.sh crawler`. `PRIVAPUB_ENV` passes any setting to the PrivaPub container.
- `run.sh up` replaces every container, Mongo included, so each run starts clean. To keep the data, republish into
+2
View File
@@ -65,6 +65,8 @@ namespace PrivaPub.Tests.Domain
var sent = await _harness.Outgoing(follower.SharedInbox);
Assert.Equal(new[] { "Create", "Update", "Delete" }, sent.Select(a => a["type"]!.GetValue<string>()));
Assert.DoesNotContain(sent[0]["to"]!.AsArray(), n => n!.GetValue<string>().EndsWith("#Public"));
// Pleroma reads an empty cc without a "/followers" address as a direct message
Assert.Contains(sent[0]["object"]!["cc"]!.AsArray(), n => n!.GetValue<string>() == alice.Followers);
Assert.Equal("cw", sent[1]["object"]!["summary"]!.GetValue<string>());
Assert.NotNull(sent[1]["object"]!["updated"]);
Assert.Equal(sent[0]["to"]!.ToJsonString(), sent[2]["to"]!.ToJsonString());
@@ -190,7 +190,7 @@ namespace PrivaPub.Tests.Federation
}
[Fact]
public void Reads_polls_from_mastodon_misskey_and_closed_ones()
public void Reads_polls_from_mastodon_misskey_akkoma_and_closed_ones()
{
var mastodon = Parse("""
{ "id": "https://m.example/q/1", "type": "Question", "attributedTo": "https://m.example/u", "content": "?", "endTime": "2026-10-02T00:00:00Z", "votersCount": 7,
@@ -204,6 +204,10 @@ namespace PrivaPub.Tests.Federation
{ "id": "https://g.example/q/1", "type": "Question", "attributedTo": "https://g.example/u", "content": "?", "closed": "2026-09-30T12:00:00Z",
"oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] }
""");
var akkoma = Parse("""
{ "id": "https://a.example/objects/1", "type": "Question", "attributedTo": "https://a.example/users/u", "content": "?", "closed": "2099-01-01T00:00:00.000000Z",
"oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] }
""");
Assert.False(mastodon.Poll.Multiple);
Assert.Equal(new[] { 4, 3 }, mastodon.Poll.Options.Select(o => o.Votes));
@@ -212,6 +216,8 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(new[] { 2, 5 }, misskey.Poll.Options.Select(o => o.Votes));
Assert.Null(misskey.Poll.VotersCount);
Assert.Equal(new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), closed.Poll.ClosedAt);
Assert.Null(akkoma.Poll.ClosedAt);
Assert.Equal(new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc), akkoma.Poll.ExpiresAt);
}
[Fact]
+2 -1
View File
@@ -60,7 +60,8 @@ namespace PrivaPub.Tests.Http
Assert.Equal(new[] { MastodonHelpers.Public }, Strings(unlistedNote["cc"]));
var quietNote = creates[quiet.Text("uri")]["object"];
Assert.Equal(new[] { followers }, Strings(quietNote["to"]));
Assert.Empty(quietNote["cc"]!.AsArray());
// named again in cc: Pleroma and Akkoma read an empty cc without a "/followers" address as a direct message
Assert.Equal(new[] { followers }, Strings(quietNote["cc"]));
var whisper = Assert.Single(await carol.Delivered(since));
Assert.Equal(direct.Text("uri"), whisper["object"].Text("id"));
Assert.Equal(new[] { carol.Id }, Strings(whisper["object"]!["to"]));
+8 -1
View File
@@ -137,11 +137,18 @@ namespace PrivaPub.Federation.Objects
JsonValue value when value.TryGetValue<string>(out var at) => Moment(at),
_ => default
};
var expiresAt = Moment(Value(note, "endTime"));
// Pleroma and Akkoma put an open poll's end in `closed`, often without `endTime`: it is closed once that has passed
if (closedAt > DateTime.UtcNow)
{
expiresAt ??= closedAt;
closedAt = default;
}
return new PostPoll
{
Options = options,
Multiple = multiple,
ExpiresAt = Moment(Value(note, "endTime")),
ExpiresAt = expiresAt,
ClosedAt = closedAt,
VotersCount = Int(note, "votersCount")
};
@@ -138,7 +138,9 @@ namespace PrivaPub.Federation.Rendering
{
PostVisibility.Circle when group != default => (new JsonArray(group.Uri, group.Flock), new JsonArray(mentions)),
PostVisibility.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())),
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)),
// Pleroma and Akkoma call a post private only if a `to` contains "/followers" or its cc is not empty, and ours
// are /groupies: with an empty cc every followers-only post would be a DM there. Naming them again says nothing new.
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions.Append(author.Followers).ToArray())),
PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()),
_ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray()))
};
+15 -5
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing;
@@ -9,13 +10,22 @@ using System.Threading.RateLimiting;
namespace PrivaPub.Infrastructure
{
public class RateLimitOptions
{
public int AccountsPerMinute { get; set; } = 10;//sign-ups, sign-ins and recoveries per client address
public int InboxBurst { get; set; } = 300;//deliveries a sending origin may make at once
public int InboxPerTenSeconds { get; set; } = 50;//and the rate it earns them back
}
public static class RateLimiting
{
public const string Accounts = "accounts";
public const string Inbox = "inbox";
public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service) =>
service.AddRateLimiter(options =>
static RateLimitOptions Limits(HttpContext context) => context.RequestServices.GetRequiredService<IOptions<RateLimitOptions>>().Value;
public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service, IConfiguration configuration) =>
service.Configure<RateLimitOptions>(configuration.GetSection("RateLimits")).AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = (context, _) =>
@@ -39,13 +49,13 @@ namespace PrivaPub.Infrastructure
};
options.AddPolicy(Accounts, context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
_ => new FixedWindowRateLimiterOptions { PermitLimit = Limits(context).AccountsPerMinute, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
options.AddPolicy(Inbox, context => RateLimitPartition.GetTokenBucketLimiter(
SenderOrigin(context.Request) ?? "unsigned:" + context.Connection.RemoteIpAddress,
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 300,
TokensPerPeriod = 50,
TokenLimit = Limits(context).InboxBurst,
TokensPerPeriod = Limits(context).InboxPerTenSeconds,
ReplenishmentPeriod = TimeSpan.FromSeconds(10),
QueueLimit = 0
}));
+1 -1
View File
@@ -59,7 +59,7 @@ try
.PrivaPubFederationConfiguration(builder.Configuration)
.PrivaPubStatisticsConfiguration(builder.Configuration)
.PrivaPubCORSConfiguration()
.PrivaPubRateLimiting()
.PrivaPubRateLimiting(builder.Configuration)
.PrivaPubOAuth(builder.Environment)
.AddScoped<PrivaPub.Api.Mastodon.Mappers.MastodonMapper>()
.AddScoped<PrivaPub.Api.Mastodon.Mappers.AccountSearch>()
+41
View File
@@ -325,6 +325,11 @@ Firefish is dead (its site has answered 410 since February 2025).
A new Misskey 2026 starts with `federation: none`.
**Pasture evidence (2026-10-03, Sharkey 2025.4.7, `tools/pasture/scenarios/sharkey.sh`):** 40 checks pass, the whole
Misskey scenario under Sharkey's name and then what only Sharkey does:
- its edits arrive as edits, and ours reach it;
- its quote carries the FEP-e232 `Link` tag and is understood.
### Pleroma 2.10.2 and Akkoma 3.20.1
**Emits**
@@ -336,6 +341,7 @@ A new Misskey 2026 starts with `federation: none`.
- **Edit history:** `formerRepresentations`, an OrderedCollection of earlier versions.
- **Reactions:** `EmojiReact{content: ":name:", tag:[Emoji]}`, several per user; separate from Like.
- **Polls:** `votersCount` (Pleroma 2.10.1, Akkoma 3.20). A vote is a `Note{name, inReplyTo, to:[], cc:[owner]}`.
An open poll's end is in `closed`, and Akkoma sends no `endTime` (pasture, 3.20.1).
- **Pleroma only:**
- `ChatMessage`, sent only to actors with `capabilities.acceptsChatMessages`;
- `Listen{Audio}`;
@@ -348,6 +354,8 @@ A new Misskey 2026 starts with `federation: none`.
- **Pleroma's inbox guard answers 400 for unknown activity types:** `Move`, `QuoteRequest` and `Bite` are not on its
list (develop, 2026-09-30). Treat that 4xx as final.
- **Signatures (Akkoma):** `host` must be signed and match; signatures up to 2 h old and up to 40 min in the future.
- **Visibility is guessed from addresses:** a post is private only if an address in `to` contains `/followers` or its
`cc` is not empty; otherwise it is direct. Our followers-only posts therefore name `/groupies` in `cc` too.
- **Activity ids** must be at least 8 bytes.
- **ObjectAgePolicy** (default in both) delists anything older than 7 days, so `published` must be accurate.
- **Quotes:** Pleroma's InlineQuotePolicy rewrites incoming quotes into "RT: url" text. **Neither reads FEP-044f
@@ -364,6 +372,35 @@ A new Misskey 2026 starts with `federation: none`.
| `ChatMessage` in as a direct message (also needed for Lemmy, Mbin and PieFed); advertise `acceptsChatMessages` only once it is answered | P1 (in), P3 (out) | `visibility: direct`, Conversations |
| `Listen`, `vcard:bday`, `backgroundUrl` | P3 | own |
**Pasture evidence (2026-10-03, Akkoma 3.20.1, `tools/pasture/scenarios/akkoma.sh`):** 44 checks pass, three runs in a
row. Akkoma publishes no image, so `tools/pasture/images/akkoma` installs its OTP release, pinned by checksum. Covered:
- discovery and follows both ways;
- posts, CW and followers-only posts;
- the `published` time kept;
- replies both ways and their notification;
- likes and boosts both ways with their undos;
- `EmojiReact` both ways and its withdrawal;
- DMs both ways and off public timelines;
- polls and votes both ways;
- quotes both ways (`quote_id` out of its API, `quoteUri` in ours);
- images with alt text both ways;
- edits with history, and deletes, both ways;
- unfollow, block and unblock;
- statistics.
It found two bugs, both fixed:
- **Followers-only posts arrived as DMs.** Akkoma, like Pleroma, calls a post private only if an address in `to` contains
`/followers` or its `cc` is not empty. Ours is `/groupies` and a post mentioning nobody had an empty `cc`. Followers-only
posts now name the followers collection in `cc` as well, which tells nobody anything new.
- **Open polls were shown as ended, and votes refused.** An open Akkoma poll carries its end in `closed`, with no
`endTime`. A `closed` in the future is now read as the end.
Seen along the way:
- Its Linkify never takes `@user@host.test` for a mention, whatever `validate_tld` says, so in the pasture Akkoma
addresses us with Pleroma's `to[]`. Real top-level domains are unaffected.
- It records our `Block` (`user_relationships`) but never reports a remote blocker as `blocked_by`.
- Its streamer crashes rendering a new DM conversation (`ConversationView`, a nil `last_status`); delivery is unaffected.
### Lemmy: 0.19.20 live (lemmy.ml); 1.0.0-beta.2 (2026-09-25) in beta since May
join-lemmy.org's federation page is out of date. Current Lemmy neither sends nor reads `stickied` or `commentsEnabled`
@@ -458,6 +495,10 @@ What it showed:
- Lemmy logs no refused activity at `warn`; the reason is in the 400's body, which our delivery does not keep. The
scenario's API notes: `sort` values are lowercase (`new`), private messages and mentions are in
`account/notification/list`, and `resolve_object` takes both `!community@host` and `@user@host`.
- **1.0 sends nothing it queued for a server before it started that server's send worker.** A worker starts, up to a
minute after the server is first seen, at the newest activity and skips everything older. On a clean pasture its
first Follow of our community was lost, so the scenario waits for the worker (`federation_queue_state`). On the
public network the same applies to the first thing a Lemmy sends to a PrivaPub it has just discovered.
- 0.19 cannot join the pasture: its rustls trusts only its bundled roots, never Caddy's CA.
### PieFed 1.7.17 and Mbin 1.10.1
+16 -4
View File
@@ -17,7 +17,19 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
- daily rollups, every touched server described and located, the admin statistics API, the opt-in crawler and `/stargazing`;
- the pasture as plugins: GoToSocial 37/37, Mastodon 49 plus 2 expected failures.
Still open: T10 (GoToSocial gaps), T12 Misskey/Sharkey, T13 Akkoma, T14 Lemmy, and installing the geolocation timer on Max.
T10 and T12–T14: v1.17.1, 2026-10-03. The pasture runs all six peers in one pass:
- GoToSocial 54 plus 1 expected failure;
- Mastodon 49 plus 2;
- Misskey 35, Sharkey 40, Akkoma 44;
- Lemmy 20 plus 3.
The expected failures are circle posts on GoToSocial and Mastodon, inbound Block, and Lemmy's relayed votes and
removals (P7). It found three bugs, all fixed:
- quick GoToSocial edits were lost;
- followers-only posts arrived as DMs on Pleroma and Akkoma;
- Akkoma's open polls showed as ended and refused votes.
Still open: installing the geolocation timer on Max (`deploy/max/setup.sh`, as root).
- [ ] P7 Threads, communities, moderation, the social graph
- [ ] P8 Signatures, discovery, the long tail
@@ -551,9 +563,9 @@ statistics gain value with every day recorded.
| T1–T4 | Tests stop sharing state they don't own. CI runs every test against a throwaway mongod. The whole server runs under test (`WebApplicationFactory`). Nothing answers 500. | v1.15.1 |
| M1–M6 | The interaction ledger: inbox answers, handler verdicts, delivery attempts, outbound requests, served and client traffic. Provenance fixes (fetched records, stored extensions, group-wrapped Update/Delete). | v1.16.0 |
| T5–T8 | Coverage sweep over HTTP: OAuth, `/clientapi`, the Mastodon API, federation GETs, inbox gaps, jobs, migrations, pages. | v1.17.0 |
| M7–M10 | Daily rollups (`InstanceDay`, `ServerDay`). Every touched server described weekly (NodeInfo usage, instance API, snapshots). Geolocation (DB-IP Lite city and ASN). Admin statistics API under `/clientapi/admin/statistics`. | v1.18.0 |
| T9–T14 | The pasture as plugins. GoToSocial gaps, then Mastodon, Misskey/Sharkey, Akkoma and Lemmy 1.0, each run also checking that peer's statistics. | v1.18.x |
| M11 | The opt-in crawler (`PrivaPub-Stargazer`) and the `/stargazing` explainer. | v1.19.0 |
| M7–M10 | Daily rollups (`InstanceDay`, `ServerDay`). Every touched server described weekly (NodeInfo usage, instance API, snapshots). Geolocation (DB-IP Lite city and ASN). Admin statistics API under `/clientapi/admin/statistics`. | v1.17.0 (planned v1.18.0) |
| T9–T14 | The pasture as plugins. GoToSocial gaps, then Mastodon, Misskey/Sharkey, Akkoma and Lemmy 1.0, each run also checking that peer's statistics. | v1.17.0, v1.17.1 |
| M11 | The opt-in crawler (`PrivaPub-Stargazer`) and the `/stargazing` explainer. | v1.17.0 (planned v1.19.0) |
Later, and not part of these steps: the public `/stargazing` statistics. It is anonymous, cached and rate-limited, and
covers per-server software, self-published counts, location as projected by the rule above, availability buckets and
+5
View File
@@ -32,3 +32,8 @@ lemmy.test {
tls internal
reverse_proxy pasture-lemmy:8536
}
akkoma.test {
tls internal
reverse_proxy pasture-akkoma:4000
}
+1
View File
@@ -25,6 +25,7 @@
"AcceptAnyCertificate": true
},
"Media": { "Root": "/tmp/privapub-media" },
"RateLimits": { "AccountsPerMinute": 1000 },
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:80", "Protocols": "Http1AndHttp2" } } },
"Serilog": {
"MinimumLevel": { "Default": "Information", "Override": { "Microsoft": "Warning", "System": "Warning" } },
+16
View File
@@ -0,0 +1,16 @@
# Akkoma publishes no image, so this is its OTP release on the Ubuntu it is built for. The "stable" zip moves; the
# checksum pins 3.20.1 and fails the build when stable has moved on.
FROM docker.io/library/ubuntu:22.04
ARG AKKOMA_ZIP=https://akkoma-updates.s3-website.fr-par.scw.cloud/stable/akkoma-amd64.zip
ARG AKKOMA_SHA256=a06d1077dc43357923ffbebbee509f1faa2d66ae6078828ad35f9ad34ec6f96c
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl unzip libncurses5 libmagic1 libmagic-mgc file postgresql-client \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "$AKKOMA_ZIP" -o /tmp/akkoma.zip \
&& echo "$AKKOMA_SHA256 /tmp/akkoma.zip" | sha256sum -c - \
&& unzip -q /tmp/akkoma.zip -d /tmp && mv /tmp/release /opt/akkoma && rm /tmp/akkoma.zip \
&& mkdir -p /var/lib/akkoma/uploads /var/lib/akkoma/static /etc/akkoma
ENV AKKOMA_CONFIG_PATH=/etc/akkoma/config.exs PLEROMA_CONFIG_PATH=/etc/akkoma/config.exs LANG=C.UTF-8 ELIXIR_ERL_OPTIONS=+fnu
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
# Trusts the pasture's CA, writes the config on first start, migrates, and runs Akkoma in the foreground.
set -e
cp /pasture/ca/root.crt /usr/local/share/ca-certificates/pasture.crt && update-ca-certificates >/dev/null
# Mint and hackney read the bundles shipped in the release (CAStore, certifi), never the system's
for bundle in /opt/akkoma/lib/castore-*/priv/cacerts.pem /opt/akkoma/lib/certifi-*/priv/cacerts.pem; do
grep -q "pasture" "$bundle" || { echo "# pasture"; cat /pasture/ca/root.crt; } >> "$bundle"
done
if [ ! -f /etc/akkoma/config.exs ]; then
# pleroma_ctl passes its arguments on unquoted, so no value may contain a space
env -u AKKOMA_CONFIG_PATH -u PLEROMA_CONFIG_PATH /opt/akkoma/bin/pleroma_ctl instance gen --force --output /etc/akkoma/config.exs --output-psql /tmp/setup.psql \
--domain akkoma.test --instance-name PastureAkkoma --admin-email admin@akkoma.test --notify-email admin@akkoma.test \
--dbhost postgres --dbname akkoma --dbuser pasture --dbpass pasture --rum N --indexable N --db-configurable N \
--uploads-dir /var/lib/akkoma/uploads --static-dir /var/lib/akkoma/static --listen-ip 0.0.0.0 --listen-port 4000 \
--media-url https://akkoma.test/media --strip-uploads-metadata N --read-uploads-description Y --anonymize-uploads N \
</dev/null
fi
/opt/akkoma/bin/pleroma_ctl migrate
exec /opt/akkoma/bin/pleroma start
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Drives PrivaPub and each peer (started by run.sh) through each other's federation, as their users would, then checks
# that PrivaPub's statistics saw it all without naming anyone.
# usage: tools/pasture/interop.sh [peer...] peers: gts (default), mastodon, misskey, lemmy, crawler
# usage: tools/pasture/interop.sh [peer...] peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy, crawler
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$here/lib/interop.sh"
+29
View File
@@ -0,0 +1,29 @@
# Akkoma 3.20.1: our own image (images/akkoma: the OTP release, pinned by checksum) on the shared Postgres. Its config
# is generated on first start; the scenario's user is made with pleroma_ctl and signs in with the password grant.
AKKOMA_IMAGE=${AKKOMA_IMAGE:-localhost/pasture-akkoma:3.20.1}
. "$here/peers/shared.sh"
akkoma_up() {
shared_postgres_up
podman image exists "$AKKOMA_IMAGE" || podman build -q -t "$AKKOMA_IMAGE" "$here/images/akkoma" >/dev/null
podman exec pasture-postgres sh -c "psql -U pasture -tc \"select 1 from pg_database where datname='akkoma'\" | grep -q 1 || createdb -U pasture akkoma"
podman exec pasture-postgres psql -U pasture -d akkoma -qc 'create extension if not exists citext; create extension if not exists pg_trgm; create extension if not exists "uuid-ossp";' >/dev/null
mkdir -p "$here/.state/akkoma"
podman run -d --replace --name pasture-akkoma --network $net \
-v "$here/.state/akkoma:/etc/akkoma:z" -v "$ca:/pasture/ca:z,ro" "$AKKOMA_IMAGE" >/dev/null
for _ in $(seq 1 120); do
site akkoma.test -s -o /dev/null -w '%{http_code}' https://akkoma.test:6443/api/v1/instance 2>/dev/null | grep -q 200 && break
sleep 3
done
podman exec pasture-akkoma /opt/akkoma/bin/pleroma_ctl user new akuser akuser@akkoma.test --password Akkoma-Pasture-Pass-1 \
--name akuser --admin --assume-yes >/dev/null 2>&1 || true
local app cid cs
app=$(site akkoma.test -s -X POST https://akkoma.test:6443/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read write follow push admin')
cid=$(echo "$app" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_id'])" 2>/dev/null)
cs=$(echo "$app" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_secret'])" 2>/dev/null)
site akkoma.test -s -X POST https://akkoma.test:6443/oauth/token --data-urlencode grant_type=password --data-urlencode username=akuser \
--data-urlencode password=Akkoma-Pasture-Pass-1 --data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs" \
--data-urlencode 'scope=read write follow push admin' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])" > "$here/.state/akkoma.token" 2>/dev/null || true
echo "akkoma: https://akkoma.test:6443"
}
+1 -1
View File
@@ -33,7 +33,7 @@ allowedPrivateNetworks:
- '192.168.0.0/16'
YML
podman run -d --replace --name pasture-$MISSKEY_NAME --network $net -e NODE_EXTRA_CA_CERTS=/pasture/ca/root.crt \
-v "$here/.state/$MISSKEY_NAME:/misskey/.config:z,ro" -v "$ca:/pasture/ca:z,ro" $MISSKEY_IMAGE >/dev/null
-v "$here/.state/$MISSKEY_NAME:${MISSKEY_HOME:-/misskey}/.config:z,ro" -v "$ca:/pasture/ca:z,ro" $MISSKEY_IMAGE >/dev/null
for _ in $(seq 1 120); do
site "$MISSKEY_NAME.test" -s -o /dev/null -w '%{http_code}' -X POST "https://$MISSKEY_NAME.test:6443/api/meta" -H 'Content-Type: application/json' -d '{}' 2>/dev/null | grep -q 200 && break
sleep 3
+8
View File
@@ -0,0 +1,8 @@
# Sharkey: the Misskey peer under its own name, image, database, Redis db and home. Its scenario is Misskey's plus what
# only Sharkey sends.
. "$here/peers/misskey.sh"
sharkey_up() {
MISSKEY_NAME=sharkey MISSKEY_IMAGE=${SHARKEY_IMAGE:-registry.activitypub.software/transfem-org/sharkey:2025.4.7} \
MISSKEY_REDIS_DB=3 MISSKEY_HOME=/sharkey misskey_up
}
+1 -1
View File
@@ -2,7 +2,7 @@
# A private test fediverse on one podman network: PrivaPub (privapub.test) and the peers asked for, behind one Caddy
# whose internal CA every side is told to accept (its root is copied to .ca/root.crt). From the workstation: PrivaPub's
# API at http://127.0.0.1:6971, every site at https://<name>.test:6443 (curl --resolve <name>.test:6443:127.0.0.1 -k).
# usage: tools/pasture/run.sh up [peer...] | down | logs <name> | ps peers: gts (default), mastodon, misskey, lemmy
# usage: tools/pasture/run.sh up [peer...] | down | logs <name> | ps peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy
set -euo pipefail
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/pasture.sh"
+142
View File
@@ -0,0 +1,142 @@
# Akkoma 3.20: discovery, follows, posts, CW, replies both ways, likes and boosts with their undos, emoji reactions
# both ways, DMs, polls, quotes both ways, edits with their history, deletes, media with alt text, the published time
# kept, blocks, statistics. Akkoma speaks the Mastodon API, with reactions under /api/v1/pleroma.
AK=https://akkoma.test:6443
acurl() { curl -sk --resolve akkoma.test:6443:127.0.0.1 "$@"; }
KT=$(cat "$here/.state/akkoma.token" 2>/dev/null)
KH="Authorization: Bearer $KT"
# what Akkoma holds of an account, newest first; and the one status whose uri is given
a_statuses() { acurl -H "$KH" "$AK/api/v1/accounts/$1/statuses?limit=40"; }
a_status_by_uri() { a_statuses "$1" | j "print(json.dumps(next((s for s in d if s['uri']=='$2'), None)))"; }
# a PrivaPub status in alice_akkoma's home whose text has the given words
p_home_id() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next((s['id'] for s in d if '$1' in s['content']), ''))"; }
echo "akkoma"
[ -n "$KT" ] && ok "Akkoma token for akuser" || { ko "Akkoma token"; return 1; }
PT=$(privapub_token alice_akkoma)
PH="Authorization: Bearer $PT"
[ -n "$PT" ] && ok "PrivaPub token for alice_akkoma" || { ko "PrivaPub token for alice_akkoma"; return 1; }
alice_self=$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
echo " discovery"
alice_on_a=$(acurl -H "$KH" "$AK/api/v2/search?q=@alice_akkoma@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$alice_on_a" ] && ok "Akkoma resolves @alice_akkoma@privapub.test" || ko "Akkoma cannot resolve alice_akkoma"
ak_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=akuser@akkoma.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$ak_on_p" ] && ok "PrivaPub resolves @akuser@akkoma.test" || ko "PrivaPub cannot resolve akuser"
echo " follows"
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/accounts/$alice_on_a/follow"
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/accounts/relationships?id[]=$alice_on_a" | j "print(d[0][\"following\"])")" = "True" ]' && ok "akuser follows alice_akkoma (Accept arrived)" || ko "Akkoma's follow not accepted"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/follow"
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$ak_on_p" | j "print(d[0][\"following\"])")" = "True" ]' && ok "alice_akkoma follows akuser (Accept arrived)" || ko "PrivaPub's follow not accepted"
echo " posts"
p_post=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=Hello Akkoma from PrivaPub&visibility=public')
p_post_id=$(echo "$p_post" | j "print(d['id'])"); p_post_uri=$(echo "$p_post" | j "print(d['uri'])")
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_post_uri")" != "null" ]' && ok "alice_akkoma's post reaches Akkoma" || ko "post missing on Akkoma"
p_post_on_a=$(a_status_by_uri "$alice_on_a" "$p_post_uri" | j "print(d['id'])")
a_post=$(acurl -X POST -H "$KH" "$AK/api/v1/statuses" -d 'status=Hello PrivaPub from Akkoma&visibility=public')
a_post_id=$(echo "$a_post" | j "print(d['id'])")
until_true 30 '[ -n "$(p_home_id "Hello PrivaPub from Akkoma")" ]' && ok "akuser's post reaches alice_akkoma's home" || ko "Akkoma's post missing on PrivaPub"
a_post_on_p=$(p_home_id "Hello PrivaPub from Akkoma")
a_created=$(echo "$a_post" | j "print(d['created_at'][:19])")
[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$a_post_on_p" | j "print(d['created_at'][:19])")" = "$a_created" ] \
&& ok "akuser's post keeps its published time" || ko "published time changed on PrivaPub"
cw_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=behind a warning&spoiler_text=spoilers&visibility=public' | j "print(d['uri'])")
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$cw_uri" | j "print(d and d[\"spoiler_text\"]==\"spoilers\" and d[\"sensitive\"])")" = "True" ]' && ok "a content warning survives to Akkoma" || ko "content warning lost on Akkoma"
fo_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=only for followers&visibility=private' | j "print(d['uri'])")
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$fo_uri" | j "print(d and d[\"visibility\"])")" = "private" ]' && ok "a followers-only post reaches Akkoma as private" || ko "followers-only post missing or widened on Akkoma"
echo " replies"
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses" -d "status=@alice_akkoma@privapub.test replying from Akkoma&in_reply_to_id=$p_post_on_a&visibility=public"
until_true 30 'curl -s -H "$PH" "$P/api/v1/notifications" | j "print(any(n[\"type\"]==\"mention\" for n in d))" | grep -q True' && ok "akuser's reply notifies alice_akkoma" || ko "reply did not notify"
until_true 15 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "the reply threads under alice_akkoma's post" || ko "reply not threaded on PrivaPub"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@akuser@akkoma.test replying from PrivaPub&in_reply_to_id=$a_post_on_p&visibility=public"
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "alice_akkoma's reply threads under akuser's post" || ko "outbound reply not threaded on Akkoma"
echo " likes, boosts and reactions"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/favourite"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/reblog"
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "1 1" ]' && ok "alice_akkoma's like and boost count on Akkoma" || ko "like or boost not counted on Akkoma"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/unfavourite"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/unreblog"
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "0 0" ]' && ok "alice_akkoma's unlike and unboost reach Akkoma" || ko "undo of like or boost not applied on Akkoma"
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/favourite"
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/reblog"
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "1 1" ]' && ok "akuser's like and boost count on PrivaPub" || ko "like or boost not counted on PrivaPub"
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/unfavourite"
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/unreblog"
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "0 0" ]' && ok "akuser's unlike and unboost reach PrivaPub" || ko "undo of like or boost not applied on PrivaPub"
acurl -o /dev/null -X PUT -H "$KH" "$AK/api/v1/pleroma/statuses/$p_post_on_a/reactions/%F0%9F%8E%89"
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(any(r[\"name\"]==\"🎉\" and r[\"count\"]==1 for r in d.get(\"emoji_reactions\") or []))")" = "True" ]' \
&& ok "akuser's 🎉 (EmojiReact) arrives as a reaction" || ko "Akkoma's reaction missing on PrivaPub"
acurl -o /dev/null -X DELETE -H "$KH" "$AK/api/v1/pleroma/statuses/$p_post_on_a/reactions/%F0%9F%8E%89"
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(len(d.get(\"emoji_reactions\") or []))")" = "0" ]' && ok "akuser's withdrawn reaction is gone" || ko "withdrawn reaction still on PrivaPub"
curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/pleroma/statuses/$a_post_on_p/reactions/%F0%9F%91%8D"
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(any(r[\"name\"]==\"👍\" and r[\"count\"]==1 for r in (d.get(\"pleroma\") or {}).get(\"emoji_reactions\") or d.get(\"emoji_reactions\") or []))")" = "True" ]' \
&& ok "alice_akkoma's 👍 is a reaction on Akkoma" || ko "PrivaPub's reaction missing on Akkoma"
curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/pleroma/statuses/$a_post_on_p/reactions/%F0%9F%91%8D"
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(len((d.get(\"pleroma\") or {}).get(\"emoji_reactions\") or d.get(\"emoji_reactions\") or []))")" = "0" ]' && ok "alice_akkoma's withdrawn reaction is gone from Akkoma" || ko "withdrawn reaction still on Akkoma"
echo " direct messages"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d 'status=@akuser@akkoma.test a secret for Akkoma&visibility=direct'
until_true 30 'acurl -H "$KH" "$AK/api/v1/conversations" | grep -q "a secret for Akkoma"' && ok "alice_akkoma's DM reaches akuser" || ko "DM missing on Akkoma"
# Akkoma's Linkify never takes @user@host.test for a mention, so its DM names alice with Pleroma's to[] instead
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses" -d 'status=a secret for PrivaPub&visibility=direct&to[]=alice_akkoma@privapub.test'
until_true 30 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a secret for PrivaPub"' && ok "akuser's DM reaches alice_akkoma" || ko "DM missing on PrivaPub"
! curl -s "$P/api/v1/timelines/public" | grep -q "a secret for PrivaPub" && ok "the DM is not on PrivaPub's public timeline" || ko "DM leaked to the public timeline"
echo " polls"
p_poll_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=cats or dogs&visibility=public&poll[options][]=cats&poll[options][]=dogs&poll[expires_in]=3600' | j "print(d['uri'])")
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_poll_uri" | j "print(len(d[\"poll\"][\"options\"]))")" = "2" ]' && ok "alice_akkoma's poll reaches Akkoma as a poll" || ko "poll missing on Akkoma"
p_poll_on_a=$(a_status_by_uri "$alice_on_a" "$p_poll_uri" | j "print(d['poll']['id'])")
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/polls/$p_poll_on_a/votes" -d 'choices[]=1'
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s[\"poll\"][\"options\"][1][\"votes_count\"] for s in d if s[\"uri\"]==\"$p_poll_uri\"))")" = "1" ]' && ok "akuser's vote counts on PrivaPub" || ko "vote not counted on PrivaPub"
a_poll=$(acurl -X POST -H "$KH" "$AK/api/v1/statuses" -d 'status=tea or coffee, Akkoma asks&visibility=public&poll[options][]=tea&poll[options][]=coffee&poll[expires_in]=3600' | j "print(d['id'])")
until_true 30 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(s[\"poll\"] and \"Akkoma asks\" in s[\"content\"] for s in d))" | grep -q True' && ok "akuser's poll reaches PrivaPub as a poll" || ko "poll missing on PrivaPub"
a_poll_on_p=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['poll']['id'] for s in d if s['poll'] and 'Akkoma asks' in s['content']))")
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/polls/$a_poll_on_p/votes" -d 'choices[]=0'
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_poll" | j "print(d[\"poll\"][\"options\"][0][\"votes_count\"])")" = "1" ]' && ok "alice_akkoma's vote counts on Akkoma" || ko "vote not counted on Akkoma"
echo " quotes"
a_quote=$(acurl -X POST -H "$KH" "$AK/api/v1/statuses" -d "status=quoting PrivaPub&visibility=public&quote_id=$p_post_on_a" | j "print(d['id'])")
until_true 30 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(\"quoting PrivaPub\" in s[\"content\"] and (s.get(\"quote\") or {}).get(\"state\")==\"accepted\" for s in d))" | grep -q True' \
&& ok "akuser's quote arrives as an accepted quote" || ko "Akkoma's quote not understood"
p_quote_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=quoting Akkoma&visibility=public&quoted_status_id=$a_post_on_p" | j "print(d['uri'])")
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_quote_uri" | j "print(d and ((d.get(\"quote\") or {}).get(\"id\") or d.get(\"quote_id\") or (d.get(\"pleroma\") or {}).get(\"quote_id\")) == \"$a_post_id\")")" = "True" ]' \
&& ok "alice_akkoma's quote is a quote on Akkoma" || ko "quote not understood by Akkoma"
echo " media"
make_png "$work/red.png"
p_media=$(curl -s -X POST -H "$PH" "$P/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square' | j "print(d['id'])")
p_media_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=a picture&visibility=public&media_ids[]=$p_media" | j "print(d['uri'])")
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_media_uri" | j "print(d[\"media_attachments\"][0][\"description\"])")" = "a red square" ]' && ok "an image with alt text reaches Akkoma" || ko "image or alt text missing on Akkoma"
a_media=$(acurl -X POST -H "$KH" "$AK/api/v1/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square from Akkoma' | j "print(d['id'])")
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses" -d "status=a picture from Akkoma&visibility=public&media_ids[]=$a_media"
until_true 30 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(\"a picture from Akkoma\" in s[\"content\"] and s[\"media_attachments\"] and \"/media/proxy/\" in s[\"media_attachments\"][0][\"url\"] and s[\"media_attachments\"][0][\"description\"]==\"a red square from Akkoma\" for s in d))" | grep -q True' \
&& ok "an image with alt text from Akkoma arrives through our proxy" || ko "Akkoma's image missing, unproxied or without alt text"
echo " edits and deletes"
curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/statuses/$p_post_id" -d 'status=Hello Akkoma from PrivaPub, edited'
until_true 30 'acurl -H "$KH" "$AK/api/v1/statuses/$p_post_on_a" | grep -q "edited"' && ok "alice_akkoma's edit reaches Akkoma" || ko "edit not applied on Akkoma"
acurl -o /dev/null -X PUT -H "$KH" "$AK/api/v1/statuses/$a_post_id" -d 'status=Hello PrivaPub from Akkoma, edited'
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$a_post_on_p/history" | j "print(len(d))")" = "2" ]' && ok "akuser's edit reaches PrivaPub with its history" || ko "Akkoma's edit not applied on PrivaPub"
cw_on_a=$(a_status_by_uri "$alice_on_a" "$cw_uri" | j "print(d['id'])")
cw_id=$(curl -s -H "$PH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s['id'] for s in d if s['uri']=='$cw_uri'))")
curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$cw_id"
until_true 30 '[ "$(acurl -o /dev/null -w "%{http_code}" -H "$KH" "$AK/api/v1/statuses/$cw_on_a")" = "404" ]' && ok "alice_akkoma's delete reaches Akkoma" || ko "delete not applied on Akkoma"
acurl -o /dev/null -X DELETE -H "$KH" "$AK/api/v1/statuses/$a_poll"
until_true 30 '! curl -s -H "$PH" "$P/api/v1/timelines/home" | grep -q "Akkoma asks"' && ok "akuser's delete reaches PrivaPub" || ko "Akkoma's delete not applied on PrivaPub"
echo " blocks and unfollows"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/unfollow"
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/accounts/relationships?id[]=$alice_on_a" | j "print(d[0][\"followed_by\"])")" = "False" ]' && ok "alice_akkoma's unfollow reaches Akkoma" || ko "unfollow not applied on Akkoma"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/block"
# Akkoma never reports a remote blocker as blocked_by, so the block is read from its user_relationships (1 is a block)
a_blocked() { podman exec pasture-postgres psql -U pasture -d akkoma -tAc "select count(*) from user_relationships r join users a on a.id=r.source_id join users b on b.id=r.target_id where r.relationship_type=1 and a.nickname='alice_akkoma@privapub.test' and b.nickname='akuser'"; }
until_true 30 '[ "$(a_blocked)" = "1" ]' && ok "alice_akkoma's block reaches Akkoma" || ko "block not applied on Akkoma"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/unblock"
until_true 30 '[ "$(a_blocked)" = "0" ]' && ok "alice_akkoma's unblock reaches Akkoma" || ko "unblock not applied on Akkoma"
echo " statistics"
stats_check akkoma.test akkoma
+5
View File
@@ -13,6 +13,8 @@ lm() {
}
# the posts Lemmy holds in a community, and the one whose ap_id is given
lm_posts() { lm GET "post/list?community_id=$1&sort=new&limit=50" | j "print(json.dumps([p['post'] for p in (d.get('items') or d.get('posts') or [])]))"; }
# 1 once Lemmy runs a send worker for privapub.test
lm_worker() { podman exec pasture-postgres psql -U pasture -d lemmy -tAc "select count(*) from federation_queue_state q join instance i on i.id=q.instance_id where i.domain='privapub.test'"; }
lm_post_by_ap() { lm_posts "$1" | j "print(json.dumps(next((p for p in d if p['ap_id']=='$2'), None)))"; }
echo "lemmy"
@@ -30,6 +32,9 @@ dogs_gid=$(curl -s -X POST $P/clientapi/group/insert -H 'Content-Type: applicati
-d "{\"avatarId\":\"$alice_id\",\"userName\":\"$dogs\",\"name\":\"Pasture dogs\",\"description\":\"a PrivaPub community\",\"isCommunity\":true}" | j "print(d['id'])")
dogs_on_lm=$(lm GET "resolve_object?q=!$dogs@privapub.test" | j "print(d['community']['id'])")
[ -n "$dogs_on_lm" ] && ok "Lemmy resolves a PrivaPub community" || ko "Lemmy cannot resolve the PrivaPub community"
# Lemmy 1.0 starts its send worker for a newly seen server at the newest activity and never sends anything queued for
# that server before then, so on a clean pasture the first follow waits for the worker (up to a minute)
until_true 45 '[ "$(lm_worker)" = "1" ]' || true
lm POST community/follow "{\"community_id\":$dogs_on_lm,\"follow\":true}" >/dev/null
until_true 30 '[ "$(lm GET "community?id=$dogs_on_lm" | j "print(((d[\"community_view\"].get(\"community_actions\") or {}).get(\"follow_state\") or \"\").lower())")" = "accepted" ]' \
&& ok "lemmyuser follows the PrivaPub community (Accept arrived)" || ko "Lemmy's community follow not accepted"
+25
View File
@@ -0,0 +1,25 @@
# Sharkey 2025.4: Misskey's scenario under Sharkey's name, then what only Sharkey does: edits both ways, and quotes that
# carry a FEP-e232 Link tag instead of `quote`.
MISSKEY_NAME=sharkey
. "$here/scenarios/misskey.sh"
unset MISSKEY_NAME
echo " sharkey only"
# Misskey's scenario ends unfollowed and blocked, so follow again both ways
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/follow"
mk following/create "{\"userId\":\"$alice_on_mk\"}" >/dev/null
until_true 30 '[ "$(mk_relation "$alice_on_mk" isFollowed)$(mk_relation "$alice_on_mk" isFollowing)" = "TrueTrue" ]' \
&& ok "alice and mkuser follow each other again" || ko "refollow after the block failed"
sk_note=$(mk notes/create '{"text":"before the edit","visibility":"public"}' | j "print(d['createdNote']['id'])")
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "before the edit"' && ok "a Sharkey note reaches alice" || ko "Sharkey note missing on PrivaPub"
mk notes/edit "{\"editId\":\"$sk_note\",\"text\":\"after the edit\",\"visibility\":\"public\"}" >/dev/null
until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(\"after the edit\" in s[\"content\"] and s[\"edited_at\"] for s in d))")" = "True" ]' \
&& ok "mkuser's edit arrives as an edit" || ko "Sharkey edit not applied on PrivaPub"
k_edit=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d 'status=PrivaPub before the edit&visibility=public')
k_edit_id=$(echo "$k_edit" | j "print(d['id'])"); k_edit_uri=$(echo "$k_edit" | j "print(d['uri'])")
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_edit_uri")" != "null" ]' || true
curl -s -o /dev/null -X PUT -H "$KH" "$P/api/v1/statuses/$k_edit_id" -d 'status=PrivaPub after the edit'
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_edit_uri" | j "print(d and d[\"text\"])")" = "PrivaPub after the edit" ]' \
&& ok "alice's edit reaches Sharkey" || ko "PrivaPub edit not applied on Sharkey"
quote_tag=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'var p=db.Post.findOne({Text:/quoting PrivaPub/}, {}, {sort:{_id:-1}}); var r=p && db.ObjectRecord.findOne({PostId:p._id.toHexString()}); print(r ? /"type"\s*:\s*"Link"/.test(r.Raw) : "none")')
[ "$quote_tag" = "true" ] && ok "Sharkey's quote carries a FEP-e232 Link tag, and is understood" || ko "Sharkey's quote record has no Link tag ($quote_tag)"