T12: Misskey 2026.10 in the pasture

peers/misskey.sh runs Misskey on the shared Postgres and Redis. Its config is generated with
the pasture's private networks allowed and a setup password, it trusts Caddy's CA through
NODE_EXTRA_CA_CERTS, and the first account it makes is the scenario's user. A new Misskey
federates with nobody, so the setup also turns federation on.

scenarios/misskey.sh adds 35 checks, all passing, as listed in docs/INTEROP.md. They cover
posts, CW, MFM source, replies, unicode reactions both ways and their withdrawal, likes as
reactions, legacy quotes both ways, polls, specified notes, media, deletes, unfollow,
block and statistics. MISSKEY_NAME and MISSKEY_IMAGE are there so Sharkey can reuse the peer.

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 13:29:17 +02:00
1 parent d4e9acdb79
commit e29da1b47a
6 files changed
+208

No files matched your search

+4
View File
@@ -410,6 +410,10 @@ tools/pasture/run.sh down # removes e
deliveries are checked through `/api/v1/accounts/:id/statuses` of the sender as Mastodon knows them, or
`Status.exists?` through `rails runner`. Its actors are numbered (`/ap/users/<id>`), so look URIs up rather than
build them. 49 checks; circle posts are an expected failure (see `docs/INTEROP.md`, Mastodon).
- **Misskey (2026.10.0):** one container on the shared Postgres and Redis. A new Misskey federates with nobody
(`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.
- **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
+15
View File
@@ -310,6 +310,21 @@ Firefish is dead (its site has answered 410 since February 2025).
| Per-attachment `sensitive`; `isCat` and other actor extras; enforce `requireSignin…` and `makeNotes…Before` on our public pages | P2 | own `privapub.*` |
| Quotes: when we send one, send every key (`quote`, `_misskey_quote`, `quoteUrl`, `quoteUri`, a FEP-e232 tag, the `RE:` fallback) | P2 | — |
**Pasture evidence (2026-10-03, Misskey 2026.10.0, `tools/pasture/scenarios/misskey.sh`):** 35 checks pass:
- discovery and follows both ways;
- posts, CW and the MFM `source` kept;
- replies threading both ways;
- 👍 and 🎉 reactions both ways, and their withdrawal;
- a like counting as a reaction;
- legacy quotes both ways (`_misskey_quote` in, `renoteId` out);
- polls and votes both ways;
- `specified` notes as DMs both ways;
- images with alt text (`comment`) both ways;
- deletes both ways, unfollow and block;
- statistics.
A new Misskey 2026 starts with `federation: none`.
### Pleroma 2.10.2 and Akkoma 3.20.1
**Emits**
+15
View File
@@ -17,3 +17,18 @@ mastodon.test {
tls internal
reverse_proxy pasture-mastodon:3000
}
misskey.test {
tls internal
reverse_proxy pasture-misskey:3000
}
sharkey.test {
tls internal
reverse_proxy pasture-sharkey:3000
}
lemmy.test {
tls internal
reverse_proxy pasture-lemmy:8536
}
+2
View File
@@ -2,6 +2,8 @@
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; repo="$(cd "$here/../.." && pwd)"
net=privapub-pasture; publish="$here/.publish"; ca="$here/.ca"
site() { curl -k --resolve "$1:6443:127.0.0.1" "${@:2}"; }
wait_http() { # url tries
for _ in $(seq 1 "${2:-60}"); do curl -fsk -o /dev/null "$1" && return 0; sleep 2; done
echo "timed out waiting for $1" >&2; return 1
+48
View File
@@ -0,0 +1,48 @@
# Misskey: one container on the shared Postgres (database misskey) and Redis (db 2). The first account, made with the
# setup password, is the scenario's user; its token is kept in .state/misskey.token.
MISSKEY_IMAGE=${MISSKEY_IMAGE:-docker.io/misskey/misskey:2026.10.0}
MISSKEY_NAME=${MISSKEY_NAME:-misskey}
. "$here/peers/shared.sh"
misskey_up() {
shared_postgres_up
shared_redis_up
podman exec pasture-postgres sh -c "psql -U pasture -tc \"select 1 from pg_database where datname='$MISSKEY_NAME'\" | grep -q 1 || createdb -U pasture $MISSKEY_NAME"
mkdir -p "$here/.state/$MISSKEY_NAME"
cat > "$here/.state/$MISSKEY_NAME/default.yml" <<YML
url: https://$MISSKEY_NAME.test/
port: 3000
db:
host: postgres
port: 5432
db: $MISSKEY_NAME
user: pasture
pass: pasture
dbReplications: false
redis:
host: redis
port: 6379
db: ${MISSKEY_REDIS_DB:-2}
fulltextSearch:
provider: sqlLike
id: 'aidx'
setupPassword: pasture-setup
allowedPrivateNetworks:
- '10.0.0.0/8'
- '172.16.0.0/12'
- '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
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
done
site "$MISSKEY_NAME.test" -s -X POST "https://$MISSKEY_NAME.test:6443/api/admin/accounts/create" -H 'Content-Type: application/json' \
-d '{"username":"mkuser","password":"Misskey-Pasture-Pass-1","setupPassword":"pasture-setup"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])" > "$here/.state/$MISSKEY_NAME.token" 2>/dev/null || true
# a new Misskey federates with nobody (federation: none) until its admin says otherwise
site "$MISSKEY_NAME.test" -s -o /dev/null -X POST "https://$MISSKEY_NAME.test:6443/api/admin/update-meta" -H 'Content-Type: application/json' \
-d "{\"i\":\"$(cat "$here/.state/$MISSKEY_NAME.token")\",\"federation\":\"all\"}"
echo "$MISSKEY_NAME: https://$MISSKEY_NAME.test:6443"
}
+124
View File
@@ -0,0 +1,124 @@
# Misskey 2026.10 (or Sharkey, with MISSKEY_NAME=sharkey): discovery, follows, posts and CW, replies both ways, Misskey
# reactions both ways, legacy quotes both ways, MFM source, polls, specified notes, media, deletes, blocks, statistics.
# A delivery is never checked with ap/show, which would fetch it from us; only with what Misskey already holds.
MK_NAME=${MISSKEY_NAME:-misskey}
MKH="$MK_NAME.test"
MK="https://$MKH:6443"
MKT=$(cat "$here/.state/$MK_NAME.token" 2>/dev/null)
# mk <endpoint> [json]: a Misskey API call as mkuser
mk() {
local body=${2:-'{}'}
site "$MKH" -s -X POST "$MK/api/$1" -H 'Content-Type: application/json' \
-d "$(python3 -c "import sys,json; d=json.loads(sys.argv[1]); d['i']=sys.argv[2]; print(json.dumps(d))" "$body" "$MKT")"
}
# the notes Misskey holds of an account, and the one whose uri is given
mk_notes() { mk users/notes "{\"userId\":\"$1\",\"limit\":50,\"withReplies\":true,\"withRenotes\":true}"; }
# users/relation answers a list even for one user
mk_relation() { mk users/relation "{\"userId\":\"$1\"}" | j "r=d[0] if isinstance(d,list) else d; print(r['$2'])"; }
mk_note_by_uri() { mk_notes "$1" | j "print(json.dumps(next((n for n in d if n.get('uri')=='$2'), None)))"; }
echo "$MK_NAME"
[ -n "$MKT" ] && ok "$MK_NAME token for mkuser" || { ko "$MK_NAME token"; return 1; }
KT=$(privapub_token "alice_$MK_NAME")
KH="Authorization: Bearer $KT"
[ -n "$KT" ] && ok "PrivaPub token for alice_$MK_NAME" || { ko "PrivaPub token for alice_$MK_NAME"; return 1; }
echo " discovery"
alice_on_mk=$(mk users/show "{\"username\":\"alice_$MK_NAME\",\"host\":\"privapub.test\"}" | j "print(d['id'])")
[ -n "$alice_on_mk" ] && ok "$MK_NAME resolves @alice_$MK_NAME@privapub.test" || ko "$MK_NAME cannot resolve alice"
mk_on_p=$(curl -s -H "$KH" "$P/api/v2/search?q=mkuser@$MKH&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$mk_on_p" ] && ok "PrivaPub resolves @mkuser@$MKH" || ko "PrivaPub cannot resolve mkuser"
echo " follows"
mk following/create "{\"userId\":\"$alice_on_mk\"}" >/dev/null
until_true 30 '[ "$(mk_relation "$alice_on_mk" isFollowing)" = "True" ]' && ok "mkuser follows alice (Accept arrived)" || ko "$MK_NAME's follow not accepted"
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/follow"
until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/accounts/relationships?id[]=$mk_on_p" | j "print(d[0][\"following\"])")" = "True" ]' && ok "alice follows mkuser (Accept arrived)" || ko "PrivaPub's follow not accepted"
echo " posts"
k_post=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=Hello $MK_NAME from PrivaPub&visibility=public")
k_post_id=$(echo "$k_post" | j "print(d['id'])"); k_post_uri=$(echo "$k_post" | j "print(d['uri'])")
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_post_uri")" != "null" ]' && ok "alice's post reaches $MK_NAME" || ko "post missing on $MK_NAME"
k_post_on_mk=$(mk_note_by_uri "$alice_on_mk" "$k_post_uri" | j "print(d['id'])")
mk_post=$(mk notes/create "{\"text\":\"Hello PrivaPub from $MK_NAME, \$[x2 big] words\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])")
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "Hello PrivaPub from $MK_NAME"' && ok "mkuser's post reaches alice's home" || ko "$MK_NAME's post missing on PrivaPub"
mk_post_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'Hello PrivaPub from $MK_NAME' in s['content']))")
[ "$(curl -s -H "$KH" "$P/api/v1/statuses/$mk_post_on_p" | j "print('\$[x2' in ((d.get('privapub') or {}).get('source') or {}).get('content',''))")" = "True" ] \
&& ok "the MFM source is kept" || ko "MFM source lost"
cw_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d 'status=behind a warning&spoiler_text=spoilers&visibility=public' | j "print(d['uri'])")
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$cw_uri" | j "print(d and d[\"cw\"])")" = "spoilers" ]' && ok "a content warning survives to $MK_NAME" || ko "content warning lost on $MK_NAME"
echo " replies"
mk notes/create "{\"text\":\"@alice_$MK_NAME@privapub.test replying from $MK_NAME\",\"replyId\":\"$k_post_on_mk\",\"visibility\":\"public\"}" >/dev/null
until_true 30 'curl -s -H "$KH" "$P/api/v1/notifications" | j "print(any(n[\"type\"]==\"mention\" for n in d))" | grep -q True' && ok "mkuser's reply notifies alice" || ko "reply did not notify"
until_true 15 '[ "$(curl -s -H "$KH" "$P/api/v1/statuses/$k_post_id/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "the reply threads under alice's post" || ko "reply not threaded on PrivaPub"
k_reply_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=@mkuser@$MKH replying from PrivaPub&in_reply_to_id=$mk_post_on_p&visibility=public" | j "print(d['uri'])")
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_reply_uri" | j "print(d and d[\"replyId\"])")" = "$mk_post" ]' && ok "alice's reply threads under mkuser's note" || ko "outbound reply not threaded on $MK_NAME"
echo " reactions"
mk notes/reactions/create "{\"noteId\":\"$k_post_on_mk\",\"reaction\":\"👍\"}" >/dev/null
until_true 30 'curl -s -H "$KH" "$P/api/v1/pleroma/statuses/$k_post_id/reactions" | j "print(any(r[\"name\"]==\"👍\" and r[\"count\"]==1 for r in d))" | grep -q True' && ok "mkuser's 👍 reaction reaches PrivaPub" || ko "reaction missing on PrivaPub"
mk notes/reactions/delete "{\"noteId\":\"$k_post_on_mk\"}" >/dev/null
until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/pleroma/statuses/$k_post_id/reactions" | j "print(len(d))")" = "0" ]' && ok "mkuser's withdrawn reaction leaves PrivaPub" || ko "reaction not withdrawn on PrivaPub"
curl -s -o /dev/null -X PUT -H "$KH" "$P/api/v1/pleroma/statuses/$mk_post_on_p/reactions/%F0%9F%8E%89"
until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_post\"}" | j "print(d[\"reactions\"].get(\"🎉\", 0))")" = "1" ]' && ok "alice's 🎉 reaction reaches $MK_NAME" || ko "reaction missing on $MK_NAME"
# Misskey keeps one reaction per user and note, so the like goes to a note of its own
mk_liked=$(mk notes/create "{\"text\":\"like this one, PrivaPub\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])")
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "like this one, PrivaPub"' || true
mk_liked_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'like this one, PrivaPub' in s['content']))")
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/statuses/$mk_liked_on_p/favourite"
until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_liked\"}" | j "print(sum(d[\"reactions\"].values()))")" = "1" ]' && ok "alice's like counts as a reaction on $MK_NAME" || ko "like not counted on $MK_NAME"
echo " quotes"
mk_quote=$(mk notes/create "{\"text\":\"quoting PrivaPub\",\"renoteId\":\"$k_post_on_mk\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])")
until_true 30 'curl -s -H "$KH" "$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 "mkuser's quote arrives as an accepted quote" || ko "$MK_NAME's quote not understood"
k_quote_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=quoting $MK_NAME&visibility=public&quoted_status_id=$mk_post_on_p" | j "print(d['uri'])")
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_quote_uri" | j "print(d and d[\"renoteId\"])")" = "$mk_post" ]' && ok "alice's quote is a quote on $MK_NAME" || ko "quote not understood by $MK_NAME"
echo " polls"
k_poll_uri=$(curl -s -X POST -H "$KH" $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 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_poll_uri" | j "print(len(d[\"poll\"][\"choices\"]))")" = "2" ]' && ok "alice's poll reaches $MK_NAME as a poll" || ko "poll missing on $MK_NAME"
k_poll_on_mk=$(mk_note_by_uri "$alice_on_mk" "$k_poll_uri" | j "print(d['id'])")
mk notes/polls/vote "{\"noteId\":\"$k_poll_on_mk\",\"choice\":1}" >/dev/null
alice_self=$(curl -s -H "$KH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s[\"poll\"][\"options\"][1][\"votes_count\"] for s in d if s[\"uri\"]==\"$k_poll_uri\"))")" = "1" ]' && ok "mkuser's vote counts on PrivaPub" || ko "vote not counted on PrivaPub"
mk_poll=$(mk notes/create "{\"text\":\"tea or coffee, $MK_NAME asks\",\"visibility\":\"public\",\"poll\":{\"choices\":[\"tea\",\"coffee\"],\"expiredAfter\":3600000}}" | j "print(d['createdNote']['id'])")
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(s[\"poll\"] and \"$MK_NAME asks\" in s[\"content\"] for s in d))" | grep -q True' && ok "mkuser's poll reaches PrivaPub as a poll" || ko "poll missing on PrivaPub"
mk_poll_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['poll']['id'] for s in d if s['poll'] and '$MK_NAME asks' in s['content']))")
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/polls/$mk_poll_on_p/votes" -d 'choices[]=0'
until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_poll\"}" | j "print(d[\"poll\"][\"choices\"][0][\"votes\"])")" = "1" ]' && ok "alice's vote counts on $MK_NAME" || ko "vote not counted on $MK_NAME"
echo " specified notes"
mk notes/create "{\"text\":\"@alice_$MK_NAME@privapub.test a secret for PrivaPub\",\"visibility\":\"specified\",\"visibleUserIds\":[\"$alice_on_mk\"]}" >/dev/null
until_true 30 'curl -s -H "$KH" "$P/api/v1/conversations" | grep -q "a secret for PrivaPub"' && ok "mkuser's specified note arrives as a DM" || ko "specified note missing on PrivaPub"
curl -s -o /dev/null -X POST -H "$KH" $P/api/v1/statuses -d "status=@mkuser@$MKH a secret for $MK_NAME&visibility=direct"
until_true 30 'mk notes/mentions "{\"visibility\":\"specified\"}" | grep -q "a secret for $MK_NAME"' && ok "alice's DM arrives as a specified note" || ko "DM missing on $MK_NAME"
echo " media"
make_png "$work/red.png"
k_media=$(curl -s -X POST -H "$KH" "$P/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square' | j "print(d['id'])")
k_media_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=a picture for $MK_NAME&visibility=public&media_ids[]=$k_media" | j "print(d['uri'])")
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_media_uri" | j "print(d[\"files\"][0][\"comment\"])")" = "a red square" ]' && ok "an image with alt text reaches $MK_NAME" || ko "image or alt text missing on $MK_NAME"
mk_file=$(site "$MKH" -s -X POST "$MK/api/drive/files/create" -F "i=$MKT" -F "file=@$work/red.png;type=image/png" -F "comment=a red square from $MK_NAME" | j "print(d['id'])")
mk notes/create "{\"text\":\"a picture from $MK_NAME\",\"visibility\":\"public\",\"fileIds\":[\"$mk_file\"]}" >/dev/null
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(\"a picture from $MK_NAME\" 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 $MK_NAME\" for s in d))" | grep -q True' \
&& ok "an image with alt text from $MK_NAME arrives through our proxy" || ko "$MK_NAME's image missing, unproxied or without alt text"
echo " deletes"
cw_on_mk=$(mk_note_by_uri "$alice_on_mk" "$cw_uri" | j "print(d['id'])")
cw_id=$(curl -s -H "$KH" "$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 "$KH" "$P/api/v1/statuses/$cw_id"
until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$cw_on_mk\"}" | j "print(d.get(\"error\",{}).get(\"code\"))")" = "NO_SUCH_NOTE" ]' && ok "alice's delete reaches $MK_NAME" || ko "delete not applied on $MK_NAME"
mk notes/delete "{\"noteId\":\"$mk_poll\"}" >/dev/null
until_true 30 '! curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "$MK_NAME asks"' && ok "mkuser's delete reaches PrivaPub" || ko "$MK_NAME's delete not applied on PrivaPub"
echo " unfollows and blocks"
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/unfollow"
until_true 30 '[ "$(mk_relation "$alice_on_mk" isFollowed)" = "False" ]' && ok "alice's unfollow reaches $MK_NAME" || ko "unfollow not applied on $MK_NAME"
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/block"
until_true 30 '[ "$(mk_relation "$alice_on_mk" isBlocked)" = "True" ]' && ok "alice's block reaches $MK_NAME" || ko "block not applied on $MK_NAME"
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/unblock"
echo " statistics"
stats_check "$MKH" "$MK_NAME"